Compare commits
29 Commits
9a15779a44
...
ce42d70741
| Author | SHA1 | Date | |
|---|---|---|---|
|
ce42d70741
|
|||
|
e7293ac496
|
|||
|
86ff4c5953
|
|||
|
504a6f410f
|
|||
|
c73363e93d
|
|||
|
43ea2cca22
|
|||
|
0ab98690eb
|
|||
|
a8b0d3c722
|
|||
|
b8e5155e2d
|
|||
|
8c75a2ddda
|
|||
|
c01dff197f
|
|||
|
38330d7020
|
|||
|
03875f2be6
|
|||
|
ff6a7ce01a
|
|||
|
87bf5d100e
|
|||
|
076c6efb38
|
|||
|
4112e38306
|
|||
|
0da3b130e4
|
|||
|
bb0895c9b5
|
|||
|
c912afd46a
|
|||
|
800a544190
|
|||
|
dfeeeb5fdf
|
|||
|
eb3604f9ea
|
|||
|
3b4318206d
|
|||
|
cf3fe84d95
|
|||
|
abb59f46a6
|
|||
|
ebc59cbf6b
|
|||
|
934d0d981b
|
|||
|
5638913b1d
|
@@ -12,6 +12,9 @@ vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
# USDA bulk data downloads (regenerated by scripts/import-usda-nutrition.ts)
|
||||
data/usda/
|
||||
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
|
||||
static/shopping/supercard.svg
|
||||
static/shopping/cumulus.svg
|
||||
src-tauri/target/
|
||||
src-tauri/*.keystore
|
||||
# Android: ignore build output and caches, track source files
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# TODO
|
||||
|
||||
## Perf (audit 2026-04-23)
|
||||
|
||||
Order = impact. Font items + app.html preload intentionally skipped.
|
||||
|
||||
- [x] 1. Lucide subpath imports — convert `from '@lucide/svelte'` barrel imports to `@lucide/svelte/icons/<kebab-name>` so Vite tree-shakes per-icon (current 748 KB shared chunk)
|
||||
- [x] 2. Chart.js dynamic import in `FitnessChart.svelte` (drop 244 KB from non-stats fitness routes)
|
||||
- [x] 3. Recipe API endpoints — drop `JSON.parse(JSON.stringify(...))` double-serialize (9 endpoints). Client-side shuffle / cache headers deferred (would require rethinking hero preload + hydration)
|
||||
- [x] 4. Favorites page — drop unnecessary `all_brief` fetch (verified Search uses `favoritesOnly` so `allRecipes` was redundant)
|
||||
- [x] 5. Replace redundant `locals.auth()` with `locals.session` across all routes (68 files, 107 sites — loaders, actions, API endpoints)
|
||||
- [x] 6. Stream fitness stats loader — muscleHeatmap, nutritionStats, periods, sharedPeriods now stream via `{#await}`. `stats` still awaited (too many chart $deriveds depend on it)
|
||||
- [x] 7. Muscle-heatmap endpoint — add projection + O(1) bucket math. Overview already had a projection; set-subfield narrowing was attempted but reverted (returned malformed sets). Timeseries cap not feasible: totals are lifetime-scoped.
|
||||
- [x] 8. Calendar payload trim — `yearDays` narrowed to `{iso, color}` (needle lookup only), new pre-filtered `feastDots` array carries feast-specific metadata. Also fixed a stray double `locals.session ?? (locals.session ?? …)` in both calendar page loaders.
|
||||
- [x] 9. History sessions endpoint — projection narrowed to exactly what SessionCard reads (drops notes, templates, mode, endTime, session-level gpsPreview); added `.lean()`.
|
||||
- [x] 10. `Cache-Control` headers: 8 h public on the shuffled recipe list endpoints (`all_brief`, `category/[c]`, `tag/[t]`, `icon/[i]`, `in_season/[m]`) — rand_array is seeded per UTC day, safe to share. 1 h public on distinct-value lists (`category`, `tag`, `icon`). 5 min public on recipe detail. `private 1h` on fitness `/exercises/filters`. Calendar page skipped (session serialised into layout HTML).
|
||||
- [x] 11. Search — debounce was already 100 ms. Instead of a server-side `_searchKey` (would duplicate text over the wire), memoise per-recipe normalized string in a `WeakMap` on the client — built lazily, reused across every subsequent keystroke.
|
||||
|
||||
## Features
|
||||
[x] on /fitness/measure, fill "Past measurements" in SSR only for the last 10 measurements. anything further should be fetched client side on mount to decreae initial page load time. use a "show more" button and paginate measurments.
|
||||
[x] on /fitness/measure (resp. their associated logging API routes), consolidate measurements by day. If we want to log another measurement, overwriting an old one, show a warning to indicate this. disparate measurements (e.g., weight and bodyfat) should not show this warning but simply be merged into one log entry for that day.
|
||||
@@ -7,7 +23,11 @@
|
||||
[x] add a button on /fitness/measure/body-parts for each measurement directly below to say "Same value", instead of having to hit +, then - to lock in same number
|
||||
[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph)
|
||||
[x] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking?
|
||||
[ ] on /fitness/stats/histoy/<part> for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?)
|
||||
[x] on /fitness/stats/histoy/<part> for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?)
|
||||
[ ] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
|
||||
[ ] swap heart emoji on recipe favorites to lucide icon
|
||||
[ ] coop and migros cards on shopping list for scanning
|
||||
[ ] login icon from lucide in header
|
||||
|
||||
## Refactor Recipe Search Component
|
||||
|
||||
|
||||
+3
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.46.8",
|
||||
"version": "1.48.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"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",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"bwip-js": "^4.10.1",
|
||||
"jsdom": "^27.2.0",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
|
||||
Generated
+9
@@ -93,6 +93,9 @@ importers:
|
||||
'@vitest/ui':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(vitest@4.1.2)
|
||||
bwip-js:
|
||||
specifier: ^4.10.1
|
||||
version: 4.10.1
|
||||
jsdom:
|
||||
specifier: ^27.2.0
|
||||
version: 27.2.0
|
||||
@@ -1194,6 +1197,10 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
bwip-js@4.10.1:
|
||||
resolution: {integrity: sha512-I/cEPiXsu7dRCp78PpVY4gdIXmbH752n8dMC+DStM77XPkrzeathdYrjnZ/i/vZPIxXTUWc+JxgJ/MvbodqPLA==}
|
||||
hasBin: true
|
||||
|
||||
cac@7.0.0:
|
||||
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -2952,6 +2959,8 @@ snapshots:
|
||||
buffer-from@1.1.2:
|
||||
optional: true
|
||||
|
||||
bwip-js@4.10.1: {}
|
||||
|
||||
cac@7.0.0: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Build-time generation of loyalty-card barcode SVGs.
|
||||
*
|
||||
* Reads card numbers from env vars and writes static/shopping/supercard.svg
|
||||
* + static/shopping/cumulus.svg. Skips cards whose env var is unset so the
|
||||
* site still builds in environments without secrets.
|
||||
*
|
||||
* SHOPPING_COOP_SUPERCARD_NUMBER → Data Matrix (Coop Supercard)
|
||||
* SHOPPING_MIGROS_CUMULUS_NUMBER → Code 128 (Migros Cumulus)
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-loyalty-cards.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { toSVG } from 'bwip-js/node';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = resolve(HERE, '..', 'static', 'shopping');
|
||||
|
||||
type CardSpec = {
|
||||
envVar: string;
|
||||
filename: string;
|
||||
bcid: 'datamatrix' | 'code128';
|
||||
scale: number;
|
||||
parsefnc?: boolean;
|
||||
};
|
||||
|
||||
const cards: CardSpec[] = [
|
||||
// Coop Supercard uses GS1 Data Matrix with FNC1 separators between fields.
|
||||
// Put ^FNC1 in the env value wherever the real symbol has a separator
|
||||
// (dmtxread -G prints them as 0x1D); parsefnc: true turns each ^FNC1 into
|
||||
// a genuine FNC1 codeword so the regenerated code matches the card.
|
||||
{ envVar: 'SHOPPING_COOP_SUPERCARD_NUMBER', filename: 'supercard.svg', bcid: 'datamatrix', scale: 6, parsefnc: true },
|
||||
{ envVar: 'SHOPPING_MIGROS_CUMULUS_NUMBER', filename: 'cumulus.svg', bcid: 'code128', scale: 3 }
|
||||
];
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
for (const card of cards) {
|
||||
const value = process.env[card.envVar]?.trim();
|
||||
const outPath = resolve(OUT_DIR, card.filename);
|
||||
|
||||
if (!value) {
|
||||
try { rmSync(outPath); } catch { /* not present */ }
|
||||
console.log(`[loyalty-cards] ${card.envVar} not set — skipped ${card.filename}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const svg = toSVG({
|
||||
bcid: card.bcid,
|
||||
text: value,
|
||||
scale: card.scale,
|
||||
includetext: false,
|
||||
paddingwidth: 8,
|
||||
paddingheight: 8,
|
||||
...(card.parsefnc ? { parsefnc: true } : {})
|
||||
});
|
||||
|
||||
writeFileSync(outPath, svg, 'utf8');
|
||||
console.log(`[loyalty-cards] wrote ${card.filename} (${card.bcid})`);
|
||||
}
|
||||
@@ -16,14 +16,23 @@ export interface CalendarDay {
|
||||
rite1962?: Rite1962Detail;
|
||||
}
|
||||
|
||||
// Compact per-day shape returned for the full year so the ring / month-grid
|
||||
// overview views can render without refetching. Kept small on purpose.
|
||||
// Compact per-day shape returned for the full window of the liturgical year.
|
||||
// Kept to the bare minimum needed client-side: the ring needs a color for the
|
||||
// needle on the selected day (which may be a ferial with no rank metadata),
|
||||
// everything else goes through the separate `feastDots` array.
|
||||
export interface YearDay {
|
||||
iso: string;
|
||||
color: string; // primary color key (WHITE/RED/...)
|
||||
}
|
||||
|
||||
// Pre-filtered list of days that render a feast dot on the ring — rank > feria
|
||||
// — with the metadata the ring and side panel need for each. Sent alongside
|
||||
// YearDay so clients don't have to filter 365 entries themselves.
|
||||
export interface FeastDot {
|
||||
iso: string;
|
||||
name: string;
|
||||
rank: string;
|
||||
color: string; // primary color key (WHITE/RED/...)
|
||||
seasonKey: string | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SeasonArc {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, Calendar } from '@lucide/svelte';
|
||||
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet, Component } from 'svelte';
|
||||
import { Lock, Ban, SearchX, TriangleAlert, CircleAlert } from '@lucide/svelte';
|
||||
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
import Ban from '@lucide/svelte/icons/ban';
|
||||
import SearchX from '@lucide/svelte/icons/search-x';
|
||||
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
|
||||
import CircleAlert from '@lucide/svelte/icons/circle-alert';
|
||||
interface BibleQuote {
|
||||
text: string;
|
||||
reference: string;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/stores';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
let { recipeId, isFavorite = $bindable(false), isLoggedIn = false } = $props<{ recipeId: string, isFavorite?: boolean, isLoggedIn?: boolean }>();
|
||||
|
||||
@@ -44,21 +45,28 @@
|
||||
<style>
|
||||
.favorite-button {
|
||||
all: unset;
|
||||
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
|
||||
filter:
|
||||
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.6))
|
||||
drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
|
||||
position: absolute;
|
||||
bottom: 0.5em;
|
||||
right: 0.5em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.favorite-button.is-favorite {
|
||||
color: #ff2d55;
|
||||
}
|
||||
|
||||
.favorite-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
.favorite-button:hover,
|
||||
.favorite-button:focus-visible {
|
||||
transform: scale(1.2);
|
||||
@@ -69,14 +77,16 @@
|
||||
<form method="post" action="?/toggleFavorite" style="display: inline;" use:enhance>
|
||||
<input type="hidden" name="recipeId" value={recipeId} />
|
||||
<input type="hidden" name="isFavorite" value={isFavorite} />
|
||||
<button
|
||||
<button
|
||||
type="submit"
|
||||
class="favorite-button"
|
||||
class:is-favorite={isFavorite}
|
||||
disabled={isLoading}
|
||||
onclick={toggleFavorite}
|
||||
aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit speichern'}
|
||||
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit speichern'}
|
||||
>
|
||||
{isFavorite ? '❤️' : '🖤'}
|
||||
<Heart size={24} strokeWidth={2} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { themeStore } from '$lib/stores/theme.svelte';
|
||||
import { Sun, Moon, SunMoon } from '@lucide/svelte';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import SunMoon from '@lucide/svelte/icons/sun-moon';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { X } from '@lucide/svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { getToasts } from '$lib/js/toast.svelte';
|
||||
|
||||
const toasts = getToasts();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from '$app/stores';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
|
||||
let { user, recipeLang = 'rezepte', lang = 'de' } = $props();
|
||||
|
||||
@@ -56,6 +57,12 @@
|
||||
background-size: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-link {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem !important;
|
||||
}
|
||||
.options-wrap {
|
||||
--menu-bg: rgba(46, 52, 64, 0.95);
|
||||
--menu-border: rgba(255,255,255,0.08);
|
||||
@@ -155,5 +162,12 @@
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<a class=entry href="/login?callbackUrl={encodeURIComponent($page.url.pathname + $page.url.search)}">Login</a>
|
||||
<a
|
||||
class="entry login-link"
|
||||
href="/login?callbackUrl={encodeURIComponent($page.url.pathname + $page.url.search)}"
|
||||
aria-label={lang === 'de' ? 'Anmelden' : 'Login'}
|
||||
title={lang === 'de' ? 'Anmelden' : 'Login'}
|
||||
>
|
||||
<LogIn size={18} />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getAngelusStreak, getCurrentTimeSlot, type TimeSlot } from '$lib/stores/angelusStreak.svelte';
|
||||
import StreakAura from '$lib/components/faith/StreakAura.svelte';
|
||||
import { Coffee, Sun, Moon } from '@lucide/svelte';
|
||||
import Coffee from '@lucide/svelte/icons/coffee';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import { tick, onMount } from 'svelte';
|
||||
|
||||
let burst = $state(false);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
|
||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
||||
|
||||
let { exerciseId } = $props();
|
||||
let { exerciseId, plain = false } = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
const exercise = $derived(getEnrichedExerciseById(exerciseId, lang));
|
||||
@@ -11,7 +11,11 @@
|
||||
</script>
|
||||
|
||||
{#if exercise}
|
||||
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
|
||||
{#if plain}
|
||||
<span class="exercise-plain">{exercise.localName}</span>
|
||||
{:else}
|
||||
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="exercise-unknown">Unknown Exercise</span>
|
||||
{/if}
|
||||
@@ -25,6 +29,10 @@
|
||||
.exercise-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.exercise-plain {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.exercise-unknown {
|
||||
color: var(--nord11);
|
||||
font-style: italic;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script>
|
||||
import { getFilterOptionsAll, searchAllExercises } from '$lib/data/exercisedb';
|
||||
import { translateTerm } from '$lib/data/exercises';
|
||||
import { Search, X, Cable, Cog, Dumbbell, PersonStanding, Shapes, Weight } from '@lucide/svelte';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Cable from '@lucide/svelte/icons/cable';
|
||||
import Cog from '@lucide/svelte/icons/cog';
|
||||
import Dumbbell from '@lucide/svelte/icons/dumbbell';
|
||||
import PersonStanding from '@lucide/svelte/icons/person-standing';
|
||||
import Shapes from '@lucide/svelte/icons/shapes';
|
||||
import Weight from '@lucide/svelte/icons/weight';
|
||||
import { page } from '$app/stores';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
@@ -11,16 +9,19 @@
|
||||
* height?: string,
|
||||
* yUnit?: string,
|
||||
* goalLine?: number,
|
||||
* tooltipFormatter?: (value: number, datasetIndex: number, dataIndex: number, label: string) => string
|
||||
* tooltipFormatter?: (value: number, datasetIndex: number, dataIndex: number, label: string) => string,
|
||||
* yMin?: number,
|
||||
* yMax?: number
|
||||
* }}
|
||||
*/
|
||||
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined, tooltipFormatter = undefined } = $props();
|
||||
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined, tooltipFormatter = undefined, yMin = undefined, yMax = undefined } = $props();
|
||||
|
||||
/** @type {HTMLCanvasElement | undefined} */
|
||||
let canvas = $state(undefined);
|
||||
/** @type {Chart | null} */
|
||||
/** @type {import('chart.js').Chart | null} */
|
||||
let chart = $state(null);
|
||||
let registered = false;
|
||||
/** @type {typeof import('chart.js').Chart | null} */
|
||||
let ChartCtor = null;
|
||||
|
||||
const nordColors = [
|
||||
'#88C0D0', '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A',
|
||||
@@ -35,11 +36,7 @@
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!canvas || !data?.datasets) return;
|
||||
if (!registered) {
|
||||
Chart.register(...registerables);
|
||||
registered = true;
|
||||
}
|
||||
if (!canvas || !data?.datasets || !ChartCtor) return;
|
||||
if (chart) chart.destroy();
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -102,7 +99,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
chart = new Chart(ctx, /** @type {any} */ ({
|
||||
chart = new ChartCtor(ctx, /** @type {any} */ ({
|
||||
type,
|
||||
data: { labels: plainLabels, datasets: plainDatasets },
|
||||
plugins,
|
||||
@@ -125,6 +122,8 @@
|
||||
},
|
||||
y: {
|
||||
beginAtZero: type === 'bar',
|
||||
suggestedMin: yMin,
|
||||
suggestedMax: yMax,
|
||||
grid: { color: gridColor },
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
@@ -178,30 +177,42 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createChart();
|
||||
requestAnimationFrame(() => {
|
||||
if (chart) {
|
||||
chart.options.animation = { duration: 300 };
|
||||
chart.options.transitions = {
|
||||
active: { animation: { duration: 200 } }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
let disposed = false;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onTheme = () => setTimeout(createChart, 100);
|
||||
mq.addEventListener('change', onTheme);
|
||||
/** @type {MutationObserver | undefined} */
|
||||
let obs;
|
||||
|
||||
const obs = new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.attributeName === 'data-theme') onTheme();
|
||||
}
|
||||
});
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
(async () => {
|
||||
const [{ Chart, registerables }] = await Promise.all([
|
||||
import('chart.js'),
|
||||
import('chartjs-adapter-date-fns')
|
||||
]);
|
||||
if (disposed) return;
|
||||
Chart.register(...registerables);
|
||||
ChartCtor = Chart;
|
||||
createChart();
|
||||
requestAnimationFrame(() => {
|
||||
if (chart) {
|
||||
chart.options.animation = { duration: 300 };
|
||||
chart.options.transitions = {
|
||||
active: { animation: { duration: 200 } }
|
||||
};
|
||||
}
|
||||
});
|
||||
mq.addEventListener('change', onTheme);
|
||||
obs = new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.attributeName === 'data-theme') onTheme();
|
||||
}
|
||||
});
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
mq.removeEventListener('change', onTheme);
|
||||
obs.disconnect();
|
||||
obs?.disconnect();
|
||||
if (chart) chart.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { untrack } from 'svelte';
|
||||
import { Heart, ExternalLink, ScanBarcode, X } from '@lucide/svelte';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
import ScanBarcode from '@lucide/svelte/icons/scan-barcode';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import MacroBreakdown from './MacroBreakdown.svelte';
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
import { detectFitnessLang } from '$lib/js/fitnessI18n';
|
||||
import { page } from '$app/stores';
|
||||
import { Beef, Droplet, Wheat } from '@lucide/svelte';
|
||||
import Beef from '@lucide/svelte/icons/beef';
|
||||
import Droplet from '@lucide/svelte/icons/droplet';
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import RingGraph from './RingGraph.svelte';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script>
|
||||
import { Coffee, Sun, Moon, Cookie } from '@lucide/svelte';
|
||||
import Coffee from '@lucide/svelte/icons/coffee';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import Cookie from '@lucide/svelte/icons/cookie';
|
||||
import { t } from '$lib/js/fitnessI18n';
|
||||
|
||||
/** @type {{ value?: 'breakfast' | 'lunch' | 'dinner' | 'snack', lang?: 'en' | 'de', onchange?: (meal: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void }} */
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
|
||||
*/
|
||||
|
||||
/** @type {{ selectedGroups?: string[], lang?: string, split?: boolean }} */
|
||||
let { selectedGroups = $bindable([]), lang = 'en', split = false } = $props();
|
||||
/** @type {{ selectedGroups?: string[], lang?: string }} */
|
||||
let { selectedGroups = $bindable([]), lang = 'en' } = $props();
|
||||
|
||||
const isEn = $derived(lang === 'en');
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
/** Currently hovered region for tooltip */
|
||||
/** @type {MuscleRegion | null} */
|
||||
let hovered = $state(null);
|
||||
let hoveredSide = $state('front');
|
||||
|
||||
const hoveredLabel = $derived.by(() => {
|
||||
if (!hovered) return null;
|
||||
@@ -108,9 +107,8 @@
|
||||
/**
|
||||
* @param {HTMLDivElement | null} container
|
||||
* @param {Record<string, MuscleRegion>} map
|
||||
* @param {string} side
|
||||
*/
|
||||
function setupEvents(container, map, side) {
|
||||
function setupEvents(container, map) {
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
|
||||
@@ -118,7 +116,6 @@
|
||||
const g = target?.closest('g[id]');
|
||||
if (g && map[g.id]) {
|
||||
hovered = map[g.id];
|
||||
hoveredSide = side;
|
||||
g.classList.add('highlighted');
|
||||
}
|
||||
});
|
||||
@@ -143,66 +140,44 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setupEvents(frontEl, FRONT_MAP, 'front');
|
||||
setupEvents(backEl, BACK_MAP, 'back');
|
||||
setupEvents(frontEl, FRONT_MAP);
|
||||
setupEvents(backEl, BACK_MAP);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if split}
|
||||
<div class="muscle-filter-split">
|
||||
<div class="split-left">
|
||||
<div class="figure">
|
||||
<div class="svg-wrap" bind:this={frontEl}>
|
||||
{@html frontSvg}
|
||||
</div>
|
||||
<div class="muscle-filter">
|
||||
<div class="body-figures">
|
||||
<div class="figure">
|
||||
<span class="figure-label">{isEn ? 'Front' : 'Vorne'}</span>
|
||||
<div class="svg-wrap" bind:this={frontEl}>
|
||||
{@html frontSvg}
|
||||
</div>
|
||||
{#if hoveredLabel && hoveredSide === 'front'}
|
||||
<div class="hover-label">{hoveredLabel}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="split-right">
|
||||
<div class="figure">
|
||||
<div class="svg-wrap" bind:this={backEl}>
|
||||
{@html backSvg}
|
||||
</div>
|
||||
<div class="figure">
|
||||
<span class="figure-label">{isEn ? 'Back' : 'Hinten'}</span>
|
||||
<div class="svg-wrap" bind:this={backEl}>
|
||||
{@html backSvg}
|
||||
</div>
|
||||
{#if hoveredLabel && hoveredSide === 'back'}
|
||||
<div class="hover-label">{hoveredLabel}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="muscle-filter">
|
||||
<div class="body-figures">
|
||||
<div class="figure">
|
||||
<div class="svg-wrap" bind:this={frontEl}>
|
||||
{@html frontSvg}
|
||||
</div>
|
||||
</div>
|
||||
<div class="figure">
|
||||
<div class="svg-wrap" bind:this={backEl}>
|
||||
{@html backSvg}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hoveredLabel}
|
||||
<div class="hover-label">{hoveredLabel}</div>
|
||||
{/if}
|
||||
<div class="hover-label" aria-live="polite">
|
||||
{hoveredLabel ?? (isEn ? 'Tap a muscle to filter' : 'Muskel antippen zum Filtern')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.muscle-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body-figures {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -211,12 +186,47 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex: 1;
|
||||
max-width: 150px;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* Tablet sidebar: narrow column, stack figures vertically */
|
||||
@media (min-width: 900px) and (max-width: 1179px) {
|
||||
.body-figures {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.figure {
|
||||
flex: initial;
|
||||
width: 100%;
|
||||
max-width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wide sidebar: let figures grow with the card width instead of capping at 180px */
|
||||
@media (min-width: 1180px) {
|
||||
.body-figures {
|
||||
gap: 1rem;
|
||||
}
|
||||
.figure {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.figure-label {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.svg-wrap {
|
||||
width: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.svg-wrap :global(svg) {
|
||||
@@ -245,25 +255,10 @@
|
||||
}
|
||||
|
||||
.hover-label {
|
||||
font-size: 0.7rem;
|
||||
min-height: 1.1em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Split mode: two independent columns for parent to position */
|
||||
.muscle-filter-split {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.split-left, .split-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.split-left .figure, .split-right .figure {
|
||||
max-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script>
|
||||
import { t } from '$lib/js/fitnessI18n';
|
||||
import { Trash2, Plus, Pencil, UserPlus, X, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import UserPlus from '@lucide/svelte/icons/user-plus';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import DatePicker from '$lib/components/DatePicker.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||
@@ -601,7 +608,8 @@
|
||||
{/if}
|
||||
{#if showEntry && !readOnly}
|
||||
<button class="end-btn" onclick={endPeriod} disabled={loading}>
|
||||
{t('end_period', lang)}
|
||||
<span class="end-btn-icon"><Check size={18} strokeWidth={2.5} /></span>
|
||||
<span class="end-btn-label">{t('end_period', lang)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1033,7 +1041,7 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.start-btn, .end-btn {
|
||||
.start-btn {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
@@ -1043,21 +1051,60 @@
|
||||
white-space: nowrap;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.start-btn {
|
||||
background: var(--nord11);
|
||||
color: white;
|
||||
}
|
||||
.end-btn {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.start-btn:disabled, .end-btn:disabled {
|
||||
.start-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Prominent end-period CTA — flat fill, full width */
|
||||
.end-btn {
|
||||
align-self: stretch;
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.8rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: var(--nord11);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background 140ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.end-btn:hover {
|
||||
background: color-mix(in srgb, var(--nord11) 88%, black);
|
||||
}
|
||||
.end-btn:active {
|
||||
background: color-mix(in srgb, var(--nord11) 80%, black);
|
||||
}
|
||||
.end-btn:focus-visible {
|
||||
outline: 2px solid var(--nord11);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.end-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.end-btn-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.end-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.status-split { flex-direction: column; gap: 0.6rem; }
|
||||
.status-side { border-left: none; padding-left: 0; border-top: 1px solid var(--color-border); padding-top: 0.6rem; flex-direction: row; gap: 1rem; }
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script>
|
||||
import { Plus, ChevronDown, Sparkles, Beef, Droplet, Wheat } from '@lucide/svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import Beef from '@lucide/svelte/icons/beef';
|
||||
import Droplet from '@lucide/svelte/icons/droplet';
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import { untrack } from 'svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { t } from '$lib/js/fitnessI18n';
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
import { Clock, Weight, Trophy, Route, Gauge, Flame } from '@lucide/svelte';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import Weight from '@lucide/svelte/icons/weight';
|
||||
import Trophy from '@lucide/svelte/icons/trophy';
|
||||
import Route from '@lucide/svelte/icons/route';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
import Flame from '@lucide/svelte/icons/flame';
|
||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script>
|
||||
import { Check, X, Play, Square } from '@lucide/svelte';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Square from '@lucide/svelte/icons/square';
|
||||
import { METRIC_LABELS } from '$lib/data/exercises';
|
||||
import RestTimer from './RestTimer.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { Cloud, CloudOff, RefreshCw, AlertTriangle } from '@lucide/svelte';
|
||||
|
||||
import Cloud from '@lucide/svelte/icons/cloud';
|
||||
import CloudOff from '@lucide/svelte/icons/cloud-off';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
/** @type {{ status: string }} */
|
||||
let { status } = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
import { EllipsisVertical, MapPin } from '@lucide/svelte';
|
||||
import EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||
import { page } from '$app/stores';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import { X } from '@lucide/svelte';
|
||||
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
let { src, poster = '', onClose } = $props();
|
||||
|
||||
/** @param {KeyboardEvent} e */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { Play, Pause } from '@lucide/svelte';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||
@@ -17,131 +19,324 @@ function formatRest(secs) {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const restActive = $derived(restTotal > 0 && restSeconds > 0);
|
||||
const restProgress = $derived(restTotal > 0 ? restSeconds / restTotal : 0);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="workout-bar" onclick={() => goto(href)} onkeydown={(e) => { if (e.key === 'Enter') goto(href); }}>
|
||||
<div class="bar-left">
|
||||
<button class="pause-btn" onclick={(e) => { e.stopPropagation(); onPauseToggle?.(); }} aria-label={paused ? 'Resume' : 'Pause'}>
|
||||
{#if paused}<Play size={16} />{:else}<Pause size={16} />{/if}
|
||||
</button>
|
||||
<span class="elapsed" class:paused>{elapsed}</span>
|
||||
<SyncIndicator status={syncStatus} />
|
||||
</div>
|
||||
{#if restTotal > 0 && restSeconds > 0}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="rest-pill" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="rest-fill" style:width="{restProgress * 100}%"></div>
|
||||
<div class="rest-controls">
|
||||
<button class="rest-adj" onclick={() => onRestAdjust?.(-30)}>-30s</button>
|
||||
<button class="rest-time" onclick={() => onRestSkip?.()}>{formatRest(restSeconds)}</button>
|
||||
<button class="rest-adj" onclick={() => onRestAdjust?.(30)}>+30s</button>
|
||||
</div>
|
||||
<div
|
||||
class="workout-fab"
|
||||
class:rest-active={restActive}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={t('active_workout', lang)}
|
||||
onclick={() => goto(href)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goto(href); } }}
|
||||
>
|
||||
<button
|
||||
class="pause-btn"
|
||||
onclick={(e) => { e.stopPropagation(); onPauseToggle?.(); }}
|
||||
aria-label={paused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{#if paused}<Play size={14} strokeWidth={2.4} />{:else}<Pause size={14} strokeWidth={2.4} />{/if}
|
||||
</button>
|
||||
|
||||
<span class="elapsed" class:paused>{elapsed}</span>
|
||||
|
||||
<span class="fab-sync"><SyncIndicator status={syncStatus} /></span>
|
||||
|
||||
<span class="fab-divider" aria-hidden="true"></span>
|
||||
|
||||
{#if restActive}
|
||||
<div class="rest-pill">
|
||||
<div class="rest-fill" style:width="{restProgress * 100}%" aria-hidden="true"></div>
|
||||
<button class="rest-adj" onclick={(e) => { e.stopPropagation(); onRestAdjust?.(-30); }} aria-label="Remove 30 seconds">−30s</button>
|
||||
<button class="rest-time" onclick={(e) => { e.stopPropagation(); onRestSkip?.(); }} aria-label="Skip rest">{formatRest(restSeconds)}</button>
|
||||
<button class="rest-adj" onclick={(e) => { e.stopPropagation(); onRestAdjust?.(30); }} aria-label="Add 30 seconds">+30s</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="bar-label">{t('active_workout', lang)}</span>
|
||||
<span class="fab-label">{t('active_workout', lang)}</span>
|
||||
<ChevronRight size={14} strokeWidth={2.4} class="fab-chevron" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workout-bar {
|
||||
/* ═══════════════════════════════════════════
|
||||
FLOATING GLASS PILL — mirrors Header.svelte nav
|
||||
═══════════════════════════════════════════ */
|
||||
.workout-fab {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem var(--space-md, 1rem);
|
||||
background: var(--color-bg-primary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bar-left {
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pause-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
height: 3rem;
|
||||
padding: 0 0.45rem 0 0.45rem;
|
||||
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
margin-inline: auto;
|
||||
|
||||
border-radius: 100px;
|
||||
background: var(--fab-bg, rgba(46, 52, 64, 0.82));
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--fab-border, rgba(255, 255, 255, 0.08));
|
||||
box-shadow: 0 4px 24px var(--fab-shadow, rgba(0, 0, 0, 0.25));
|
||||
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
display: flex;
|
||||
transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
animation: fab-rise 380ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
|
||||
/* token defaults (dark bar) */
|
||||
--fab-text: #c9c9c9;
|
||||
--fab-text-strong: #fff;
|
||||
--fab-text-muted: rgba(255, 255, 255, 0.55);
|
||||
--fab-btn-bg: rgba(255, 255, 255, 0.08);
|
||||
--fab-btn-bg-hover: rgba(255, 255, 255, 0.16);
|
||||
--fab-btn-border: rgba(255, 255, 255, 0.14);
|
||||
--fab-divider: rgba(255, 255, 255, 0.12);
|
||||
--fab-accent: var(--blue, #5e81ac);
|
||||
--fab-paused: var(--nord13, #ebcb8b);
|
||||
}
|
||||
|
||||
.workout-fab:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px var(--fab-shadow, rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.workout-fab:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.workout-fab {
|
||||
--fab-bg: rgba(20, 20, 20, 0.78);
|
||||
--fab-border: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .workout-fab {
|
||||
--fab-bg: rgba(20, 20, 20, 0.78);
|
||||
--fab-border: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:global(:root[data-theme="light"]) .workout-fab {
|
||||
--fab-bg: rgba(255, 255, 255, 0.82);
|
||||
--fab-border: rgba(0, 0, 0, 0.08);
|
||||
--fab-shadow: rgba(0, 0, 0, 0.1);
|
||||
--fab-text: #555;
|
||||
--fab-text-strong: var(--nord0, #2e3440);
|
||||
--fab-text-muted: rgba(0, 0, 0, 0.5);
|
||||
--fab-btn-bg: rgba(0, 0, 0, 0.05);
|
||||
--fab-btn-bg-hover: rgba(0, 0, 0, 0.1);
|
||||
--fab-btn-border: rgba(0, 0, 0, 0.12);
|
||||
--fab-divider: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .workout-fab {
|
||||
--fab-bg: rgba(255, 255, 255, 0.82);
|
||||
--fab-border: rgba(0, 0, 0, 0.08);
|
||||
--fab-shadow: rgba(0, 0, 0, 0.1);
|
||||
--fab-text: #555;
|
||||
--fab-text-strong: var(--nord0, #2e3440);
|
||||
--fab-text-muted: rgba(0, 0, 0, 0.5);
|
||||
--fab-btn-bg: rgba(0, 0, 0, 0.05);
|
||||
--fab-btn-bg-hover: rgba(0, 0, 0, 0.1);
|
||||
--fab-btn-border: rgba(0, 0, 0, 0.12);
|
||||
--fab-divider: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
PAUSE BUTTON — small pill icon button (matches nav hover tile)
|
||||
═══════════════════════════════════════════ */
|
||||
.pause-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 100px;
|
||||
background: var(--fab-btn-bg);
|
||||
border: 1px solid var(--fab-btn-border);
|
||||
color: var(--fab-text-strong);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 140ms, color 140ms, transform 120ms;
|
||||
}
|
||||
.pause-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: var(--fab-btn-bg-hover);
|
||||
color: var(--fab-accent);
|
||||
}
|
||||
.pause-btn:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
ELAPSED TIME — dominant numeric
|
||||
═══════════════════════════════════════════ */
|
||||
.elapsed {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--fab-text-strong);
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
.elapsed.paused {
|
||||
color: var(--nord13);
|
||||
color: var(--fab-paused);
|
||||
}
|
||||
.bar-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
.fab-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--fab-text-muted);
|
||||
}
|
||||
|
||||
.fab-divider {
|
||||
width: 1px;
|
||||
height: 1.2rem;
|
||||
background: var(--fab-divider);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
RIGHT-SIDE LABEL / CHEVRON — idle state
|
||||
═══════════════════════════════════════════ */
|
||||
.fab-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--fab-text);
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.workout-fab :global(.fab-chevron) {
|
||||
color: var(--fab-text-muted);
|
||||
margin-right: 0.35rem;
|
||||
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), color 140ms;
|
||||
}
|
||||
.workout-fab:hover :global(.fab-chevron) {
|
||||
color: var(--fab-text-strong);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
REST PILL — inner pill with animated progress fill
|
||||
═══════════════════════════════════════════ */
|
||||
.rest-pill {
|
||||
position: relative;
|
||||
height: 2.2rem;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
height: 2.1rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 100px;
|
||||
background: var(--fab-btn-bg);
|
||||
overflow: hidden;
|
||||
background: var(--nord0);
|
||||
min-width: 10rem;
|
||||
isolation: isolate;
|
||||
}
|
||||
.rest-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--blue);
|
||||
border-radius: 8px;
|
||||
right: auto;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--fab-accent), transparent 55%) 0%,
|
||||
var(--fab-accent) 100%
|
||||
);
|
||||
transition: width 1s linear;
|
||||
z-index: -1;
|
||||
}
|
||||
.rest-controls {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
z-index: 1;
|
||||
}
|
||||
.rest-time {
|
||||
.rest-time,
|
||||
.rest-adj {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--fab-text-strong);
|
||||
font-family: inherit;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 100px;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.rest-time {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
min-width: 3.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.rest-adj {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
opacity: 0.7;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.rest-time:hover,
|
||||
.rest-adj:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
opacity: 1;
|
||||
}
|
||||
:global(:root[data-theme="light"]) .rest-time:hover,
|
||||
:global(:root[data-theme="light"]) .rest-adj:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
MOUNT ANIMATION
|
||||
═══════════════════════════════════════════ */
|
||||
@keyframes fab-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
NARROW SCREENS — tighten spacing
|
||||
═══════════════════════════════════════════ */
|
||||
@media (max-width: 420px) {
|
||||
.workout-fab {
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
.fab-label {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.elapsed {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.rest-pill {
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.rest-adj {
|
||||
padding: 0.25rem 0.35rem;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workout-fab {
|
||||
animation: none;
|
||||
}
|
||||
.workout-fab:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<script>
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
|
||||
/** @type {{
|
||||
* exerciseId: string,
|
||||
* bodyPart?: string | null,
|
||||
* equipment?: string | null,
|
||||
* detailsHref?: string | null,
|
||||
* detailsLabel?: string,
|
||||
* exerciseIndex: number,
|
||||
* totalExercises: number,
|
||||
* sets: Array<{ completed?: boolean }>,
|
||||
* activeSetIdx: number,
|
||||
* labels: { exerciseOf: (i: number, n: number) => string, setOf: (i: number, n: number) => string, done: (n: number) => string },
|
||||
* }} */
|
||||
let {
|
||||
exerciseId,
|
||||
bodyPart = null,
|
||||
equipment = null,
|
||||
detailsHref = null,
|
||||
detailsLabel = 'Exercise details',
|
||||
exerciseIndex,
|
||||
totalExercises,
|
||||
sets,
|
||||
activeSetIdx,
|
||||
labels
|
||||
} = $props();
|
||||
|
||||
const totalSets = $derived(sets.length);
|
||||
const doneSets = $derived(sets.filter((s) => s.completed).length);
|
||||
const allDone = $derived(totalSets > 0 && doneSets === totalSets);
|
||||
</script>
|
||||
|
||||
<section class="focus-card" aria-label="Current exercise">
|
||||
<header class="focus-eyebrow">
|
||||
<span class="focus-step">{labels.exerciseOf(exerciseIndex + 1, totalExercises)}</span>
|
||||
{#if bodyPart}
|
||||
<span class="focus-dot-sep" aria-hidden="true">·</span>
|
||||
<span class="focus-meta">{bodyPart}</span>
|
||||
{/if}
|
||||
{#if equipment}
|
||||
<span class="focus-dot-sep" aria-hidden="true">·</span>
|
||||
<span class="focus-meta">{equipment}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="focus-name-row">
|
||||
<h2 class="focus-name"><ExerciseName {exerciseId} plain /></h2>
|
||||
{#if detailsHref}
|
||||
<a class="focus-details" href={detailsHref} aria-label={detailsLabel} title={detailsLabel}>
|
||||
<ChevronRight size={18} strokeWidth={2.2} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="focus-progress">
|
||||
<span class="focus-set-label" class:complete={allDone}>
|
||||
{allDone ? labels.done(totalSets) : labels.setOf(activeSetIdx + 1, totalSets)}
|
||||
</span>
|
||||
<span class="focus-dots" aria-hidden="true">
|
||||
{#each sets as s, si (si)}
|
||||
<span
|
||||
class="focus-dot"
|
||||
class:filled={s.completed}
|
||||
class:current={si === activeSetIdx && !s.completed}
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.focus-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.1rem 1.25rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
|
||||
.focus-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary);
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
.focus-step {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.focus-meta {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.focus-dot-sep {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Big display name */
|
||||
.focus-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.focus-name {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.focus-details {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 100px;
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: background 140ms, color 140ms, transform 140ms;
|
||||
}
|
||||
.focus-details:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Set progress line */
|
||||
.focus-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.focus-set-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.focus-set-label.complete {
|
||||
color: var(--nord14);
|
||||
}
|
||||
.focus-dots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.focus-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background 180ms, transform 180ms, border-color 180ms;
|
||||
}
|
||||
.focus-dot.filled {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.focus-dot.current {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 55%);
|
||||
border-color: var(--color-primary);
|
||||
transform: scale(1.25);
|
||||
animation: focus-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes focus-dot-pulse {
|
||||
0%, 100% { transform: scale(1.25); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.focus-card {
|
||||
padding: 0.9rem 1rem 0.85rem;
|
||||
}
|
||||
.focus-name {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,687 @@
|
||||
<script>
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
|
||||
/**
|
||||
* @typedef {{ exerciseId: string, sets: Array<{ completed?: boolean }> }} RailExercise
|
||||
*/
|
||||
|
||||
/** @type {{
|
||||
* exercises: RailExercise[],
|
||||
* activeIdx: number,
|
||||
* activeSetIdx: number,
|
||||
* elapsedLabel: string,
|
||||
* paused?: boolean,
|
||||
* syncStatus?: string,
|
||||
* setsDone: number,
|
||||
* setsTotal: number,
|
||||
* addLabel: string,
|
||||
* pauseLabel?: string,
|
||||
* resumeLabel?: string,
|
||||
* removeLabel?: string,
|
||||
* previousData?: Record<string, Array<{ weight?: number | null, reps?: number | null }>>,
|
||||
* weightUnit?: string,
|
||||
* onPauseToggle?: () => void,
|
||||
* onFocus: (idx: number) => void,
|
||||
* onAddExercise: () => void,
|
||||
* onRemove?: (idx: number) => void,
|
||||
* onReorder?: (fromIdx: number, toIdx: number) => void,
|
||||
* }} */
|
||||
let {
|
||||
exercises,
|
||||
activeIdx,
|
||||
activeSetIdx,
|
||||
elapsedLabel,
|
||||
paused = false,
|
||||
syncStatus = 'idle',
|
||||
setsDone,
|
||||
setsTotal,
|
||||
addLabel,
|
||||
pauseLabel = 'Pause',
|
||||
resumeLabel = 'Resume',
|
||||
removeLabel = 'Remove exercise',
|
||||
previousData = {},
|
||||
weightUnit = 'kg',
|
||||
title,
|
||||
onPauseToggle,
|
||||
onFocus,
|
||||
onAddExercise,
|
||||
onRemove,
|
||||
onReorder
|
||||
} = $props();
|
||||
|
||||
/** Drag-and-drop state */
|
||||
/** @type {number | null} */
|
||||
let draggedIdx = $state(null);
|
||||
/** @type {number | null} */
|
||||
let dragOverIdx = $state(null);
|
||||
|
||||
/** @type {HTMLOListElement | null} */
|
||||
let listEl = $state(null);
|
||||
|
||||
// Keep the active chip at the top of the scrollable list so the user sees current + the next two
|
||||
$effect(() => {
|
||||
if (!listEl) return;
|
||||
const idx = activeIdx;
|
||||
if (idx < 0) return;
|
||||
const items = listEl.querySelectorAll('.rail-item');
|
||||
const target = /** @type {HTMLElement | undefined} */ (items[idx]);
|
||||
if (!target) return;
|
||||
// Use scrollTop directly to keep the scroll local to the list (avoid page scroll)
|
||||
const listTop = listEl.getBoundingClientRect().top;
|
||||
const itemTop = target.getBoundingClientRect().top;
|
||||
listEl.scrollTo({ top: listEl.scrollTop + (itemTop - listTop), behavior: 'smooth' });
|
||||
});
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDragStart(e, idx) {
|
||||
draggedIdx = idx;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox requires data to be set to initiate a drag
|
||||
e.dataTransfer.setData('text/plain', String(idx));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDragOver(e, idx) {
|
||||
if (draggedIdx == null || draggedIdx === idx) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
dragOverIdx = idx;
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDrop(e, idx) {
|
||||
e.preventDefault();
|
||||
if (draggedIdx != null && draggedIdx !== idx && onReorder) {
|
||||
onReorder(draggedIdx, idx);
|
||||
}
|
||||
draggedIdx = null;
|
||||
dragOverIdx = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedIdx = null;
|
||||
dragOverIdx = null;
|
||||
}
|
||||
|
||||
const progressPct = $derived(setsTotal > 0 ? (setsDone / setsTotal) * 100 : 0);
|
||||
|
||||
/**
|
||||
* What to rack: starting weight × reps for the first set.
|
||||
* Falls back to the previous session's first set if the current plan is blank.
|
||||
* @param {RailExercise} ex
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function startingLoadLabel(ex) {
|
||||
const first = ex.sets[0];
|
||||
const prev = previousData[ex.exerciseId]?.[0];
|
||||
/** @type {number | null | undefined} */
|
||||
const w = (first && typeof first === 'object' && 'weight' in first ? /** @type {any} */(first).weight : null) ?? prev?.weight;
|
||||
/** @type {number | null | undefined} */
|
||||
const r = (first && typeof first === 'object' && 'reps' in first ? /** @type {any} */(first).reps : null) ?? prev?.reps;
|
||||
if (w != null && w > 0 && r != null && r > 0) return `${w} ${weightUnit} × ${r}`;
|
||||
if (w != null && w > 0) return `${w} ${weightUnit}`;
|
||||
if (r != null && r > 0) return `× ${r}`;
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="workout-rail" aria-label="Workout overview">
|
||||
<header class="rail-header">
|
||||
{#if title}
|
||||
<div class="rail-title">{@render title()}</div>
|
||||
{/if}
|
||||
<div class="rail-timer-row">
|
||||
<button
|
||||
class="rail-pause"
|
||||
onclick={() => onPauseToggle?.()}
|
||||
aria-label={paused ? resumeLabel : pauseLabel}
|
||||
type="button"
|
||||
>
|
||||
{#if paused}<Play size={14} strokeWidth={2.4} />{:else}<Pause size={14} strokeWidth={2.4} />{/if}
|
||||
</button>
|
||||
<span class="rail-elapsed" class:paused>{elapsedLabel}</span>
|
||||
<span class="rail-sync"><SyncIndicator status={syncStatus} /></span>
|
||||
</div>
|
||||
<div class="rail-progress">
|
||||
<div class="rail-progress-bar">
|
||||
<div class="rail-progress-fill" style:width="{progressPct}%"></div>
|
||||
</div>
|
||||
<span class="rail-progress-label">{setsDone}<span class="rail-progress-sep">/</span>{setsTotal}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol class="rail-list" bind:this={listEl}>
|
||||
{#each exercises as ex, i (i)}
|
||||
{@const isActive = i === activeIdx}
|
||||
{@const done = ex.sets.filter((s) => s.completed).length}
|
||||
{@const complete = done === ex.sets.length && ex.sets.length > 0}
|
||||
{@const load = startingLoadLabel(ex)}
|
||||
{@const isDragging = draggedIdx === i}
|
||||
{@const isDragOver = dragOverIdx === i && draggedIdx !== i}
|
||||
{@const dropAbove = isDragOver && (draggedIdx ?? i) > i}
|
||||
{@const dropBelow = isDragOver && (draggedIdx ?? i) < i}
|
||||
<li
|
||||
class="rail-item"
|
||||
class:dragging={isDragging}
|
||||
class:drop-above={dropAbove}
|
||||
class:drop-below={dropBelow}
|
||||
class:active={isActive}
|
||||
class:complete
|
||||
draggable={onReorder ? 'true' : undefined}
|
||||
ondragstart={(e) => onDragStart(e, i)}
|
||||
ondragover={(e) => onDragOver(e, i)}
|
||||
ondrop={(e) => onDrop(e, i)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
<button
|
||||
class="rail-chip"
|
||||
class:active={isActive}
|
||||
class:complete
|
||||
onclick={() => onFocus(i)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<span class="rail-chip-index">{i + 1}</span>
|
||||
<span class="rail-chip-body">
|
||||
<span class="rail-chip-name"><ExerciseName exerciseId={ex.exerciseId} plain /></span>
|
||||
{#if load}
|
||||
<span class="rail-chip-load" aria-label="Starting load">{load}</span>
|
||||
{/if}
|
||||
<span class="rail-chip-dots" aria-hidden="true">
|
||||
{#each ex.sets as s, si (si)}
|
||||
<span
|
||||
class="rail-dot"
|
||||
class:filled={s.completed}
|
||||
class:current={isActive && si === activeSetIdx && !s.completed}
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
</span>
|
||||
{#if complete}
|
||||
<span class="rail-chip-count done" aria-label="Exercise complete">
|
||||
<Check size={14} strokeWidth={2.8} />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="rail-chip-count">{done}/{ex.sets.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if onRemove}
|
||||
<button
|
||||
class="rail-chip-remove"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove?.(i); }}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
type="button"
|
||||
draggable="false"
|
||||
ondragstart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<X size={14} strokeWidth={2.6} />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<button class="rail-add" onclick={onAddExercise} type="button">
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
<span>{addLabel}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.workout-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1rem 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Header: elapsed + progress */
|
||||
.rail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.25rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.rail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-title :global(input) {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.1rem 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
outline: none;
|
||||
}
|
||||
.rail-title :global(input::placeholder) {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rail-title :global(input:focus) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.rail-timer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.rail-pause {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 140ms, color 140ms, border-color 140ms, transform 120ms;
|
||||
}
|
||||
.rail-pause:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.rail-pause:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.rail-elapsed {
|
||||
flex: 1;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-elapsed.paused {
|
||||
color: var(--nord13);
|
||||
}
|
||||
.rail-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.rail-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rail-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--color-primary), transparent 40%), var(--color-primary));
|
||||
transition: width 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.rail-progress-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.rail-progress-sep {
|
||||
color: var(--color-text-tertiary);
|
||||
margin-inline: 0.1rem;
|
||||
}
|
||||
|
||||
/* Chip list */
|
||||
.rail-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 100px;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--color-text-tertiary), transparent 50%);
|
||||
}
|
||||
|
||||
/* Row wrapper holds chip + remove button, carries drag state */
|
||||
.rail-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
transition: opacity 140ms;
|
||||
}
|
||||
.rail-item[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
.rail-item[draggable='true']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.rail-item.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.rail-item.drop-above::before,
|
||||
.rail-item.drop-below::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary), transparent 70%);
|
||||
}
|
||||
.rail-item.drop-above::before {
|
||||
top: -3px;
|
||||
}
|
||||
.rail-item.drop-below::after {
|
||||
bottom: -3px;
|
||||
}
|
||||
|
||||
.rail-chip {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem 0.55rem 0.55rem 0.45rem;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 140ms, border-color 140ms, transform 120ms;
|
||||
}
|
||||
.rail-chip:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.rail-chip:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.rail-chip.active {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 82%);
|
||||
border-color: transparent;
|
||||
}
|
||||
.rail-chip.active:hover {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 76%);
|
||||
}
|
||||
.rail-chip.complete {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.rail-chip.complete.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* × remove — overlays the set counter on hover (same spot) */
|
||||
.rail-chip-remove {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.4rem;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 100px;
|
||||
color: var(--nord11);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms, background 140ms, transform 120ms;
|
||||
z-index: 1;
|
||||
}
|
||||
.rail-item:hover .rail-chip-remove,
|
||||
.rail-chip-remove:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.rail-chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--nord11), transparent 82%);
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
}
|
||||
.rail-chip-remove:active {
|
||||
transform: translateY(-50%) scale(0.94);
|
||||
}
|
||||
|
||||
.rail-chip-index {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-tertiary);
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.rail-chip.active .rail-chip-index {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.rail-chip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-chip-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-chip.active .rail-chip-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Starting weight hint — "what to rack" */
|
||||
.rail-chip-load {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--color-text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-chip.active .rail-chip-load {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.rail-chip.complete .rail-chip-load {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in srgb, currentColor, transparent 60%);
|
||||
}
|
||||
|
||||
.rail-chip-dots {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rail-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-border);
|
||||
transition: background 180ms, transform 180ms;
|
||||
}
|
||||
.rail-dot.filled {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 15%);
|
||||
}
|
||||
.rail-dot.current {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.3);
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
/* Set counter — visible by default, fades out when × takes over on hover */
|
||||
.rail-chip-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.55rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 140ms;
|
||||
}
|
||||
.rail-chip.active .rail-chip-count {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
/* Completed exercise: matches the set-complete check button from SetTable */
|
||||
.rail-chip-count.done {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--nord14);
|
||||
background: var(--nord14);
|
||||
color: white;
|
||||
}
|
||||
.rail-item:hover .rail-chip-count {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Add exercise button */
|
||||
.rail-add {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms, color 140ms;
|
||||
}
|
||||
.rail-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Narrow viewports: vertical list, compact chip, dots inline next to name */
|
||||
@media (max-width: 899px) {
|
||||
.workout-rail {
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 0.75rem 0.6rem;
|
||||
}
|
||||
/* Title full-width row, timer + progress share a second row */
|
||||
.rail-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"title title"
|
||||
"timer progress";
|
||||
column-gap: 0.75rem;
|
||||
row-gap: 0.5rem;
|
||||
padding: 0 0.15rem 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.rail-title { grid-area: title; }
|
||||
.rail-timer-row {
|
||||
grid-area: timer;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.rail-elapsed {
|
||||
flex: initial;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.rail-progress {
|
||||
grid-area: progress;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Dots jump up next to the name; load sits below on its own row */
|
||||
.rail-chip-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"name dots"
|
||||
"load load";
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.rail-chip-name { grid-area: name; }
|
||||
.rail-chip-dots { grid-area: dots; flex-wrap: nowrap; }
|
||||
.rail-chip-load { grid-area: load; }
|
||||
.rail-chip {
|
||||
padding: 0.5rem 0.55rem 0.5rem 0.4rem;
|
||||
}
|
||||
/* Scrollable only on mobile — desktop lets the rail grow */
|
||||
.rail-list {
|
||||
max-height: 10.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Smaller completion checkmark */
|
||||
.rail-chip-count {
|
||||
min-width: 1.25rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.rail-chip-count.done {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
.rail-chip-count.done :global(svg) {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
.rail-add {
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { UtensilsCrossed, X } from '@lucide/svelte';
|
||||
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
let {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import "$lib/css/shake.css";
|
||||
import "$lib/css/icon.css";
|
||||
import { onMount } from "svelte";
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
let {
|
||||
recipe,
|
||||
@@ -182,10 +183,13 @@ function preloadHeroImage() {
|
||||
|
||||
.favorite-indicator{
|
||||
position: absolute;
|
||||
font-size: 2rem;
|
||||
top: 0.1em;
|
||||
left: 0.1em;
|
||||
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8));
|
||||
top: 0.4em;
|
||||
left: 0.4em;
|
||||
display: flex;
|
||||
color: #ff2d55;
|
||||
filter:
|
||||
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.7))
|
||||
drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.translation-badge{
|
||||
@@ -240,7 +244,9 @@ function preloadHeroImage() {
|
||||
<img class="image" class:loaded={isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" onload={() => isloaded=true}/>
|
||||
</div>
|
||||
{#if showFavoriteIndicator && isFavorite}
|
||||
<div class="favorite-indicator">❤️</div>
|
||||
<div class="favorite-indicator" aria-label="Favorit">
|
||||
<Heart size={28} strokeWidth={2} fill="currentColor" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if translationStatus !== undefined}
|
||||
<div class="translation-badge {translationStatus || 'none'}">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import "$lib/css/shake.css";
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
let {
|
||||
recipe,
|
||||
@@ -143,9 +144,11 @@
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
left: 0.5em;
|
||||
font-size: 1.1rem;
|
||||
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
|
||||
filter: drop-shadow(0 0 3px rgba(0,0,0,0.8));
|
||||
display: flex;
|
||||
color: #ff2d55;
|
||||
filter:
|
||||
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.7))
|
||||
drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -156,7 +159,9 @@
|
||||
<div class="compact-card" onclick={activateTransitions}>
|
||||
<a href="{routePrefix}/{recipe.short_name}" class="card-link" aria-label={recipe.name}></a>
|
||||
{#if showFavoriteIndicator && isFavorite}
|
||||
<span class="favorite">❤️</span>
|
||||
<span class="favorite" aria-label="Favorit">
|
||||
<Heart size={18} strokeWidth={2} fill="currentColor" />
|
||||
</span>
|
||||
{/if}
|
||||
<div class="img-wrap" style:background-color={img_color}>
|
||||
<img
|
||||
|
||||
@@ -4,8 +4,12 @@ import Pen from '$lib/assets/icons/Pen.svelte'
|
||||
import Cross from '$lib/assets/icons/Cross.svelte'
|
||||
import Plus from '$lib/assets/icons/Plus.svelte'
|
||||
import Check from '$lib/assets/icons/Check.svelte'
|
||||
import { Timer, Wheat, Croissant, Flame, CookingPot, UtensilsCrossed } from '@lucide/svelte';
|
||||
|
||||
import Timer from '@lucide/svelte/icons/timer';
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import Croissant from '@lucide/svelte/icons/croissant';
|
||||
import Flame from '@lucide/svelte/icons/flame';
|
||||
import CookingPot from '@lucide/svelte/icons/cooking-pot';
|
||||
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
|
||||
import "$lib/css/action_button.css"
|
||||
|
||||
import { do_on_key } from '$lib/components/recipes/do_on_key.js'
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script>
|
||||
import { Timer, Wheat, Croissant, Flame, CookingPot, UtensilsCrossed } from '@lucide/svelte';
|
||||
import Timer from '@lucide/svelte/icons/timer';
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import Croissant from '@lucide/svelte/icons/croissant';
|
||||
import Flame from '@lucide/svelte/icons/flame';
|
||||
import CookingPot from '@lucide/svelte/icons/cooking-pot';
|
||||
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
|
||||
let { data } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
|
||||
@@ -122,6 +122,32 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {string} s */
|
||||
function normalizeSearchText(s) {
|
||||
return s.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.replace(/­|/g, '');
|
||||
}
|
||||
|
||||
// Memoised normalized search string per recipe. Building it is the hot
|
||||
// path (NFD + regex replace for every recipe × every keystroke), so we
|
||||
// compute it once per recipe array and reuse across keystrokes. Shipping
|
||||
// a pre-normalized `_searchKey` from the server would duplicate the text
|
||||
// fields over the wire — this keeps the payload small and amortises the
|
||||
// cost on the client instead.
|
||||
/** @type {WeakMap<object, string>} */
|
||||
const searchIndex = new WeakMap();
|
||||
/** @param {any} recipe */
|
||||
function searchStringFor(recipe) {
|
||||
const cached = searchIndex.get(recipe);
|
||||
if (cached !== undefined) return cached;
|
||||
const raw = [recipe.name || '', recipe.description || '', ...(recipe.tags || [])].join(' ');
|
||||
const norm = normalizeSearchText(raw);
|
||||
searchIndex.set(recipe, norm);
|
||||
return norm;
|
||||
}
|
||||
|
||||
// Perform search directly (no worker)
|
||||
/** @param {string} query */
|
||||
function performSearch(query) {
|
||||
@@ -138,25 +164,11 @@
|
||||
}
|
||||
|
||||
// Normalize and split search query
|
||||
const searchText = query.toLowerCase().trim()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, "");
|
||||
const searchTerms = searchText.split(" ").filter((/** @type {string} */ term) => term.length > 0);
|
||||
const searchText = normalizeSearchText(query.trim());
|
||||
const searchTerms = searchText.split(' ').filter((/** @type {string} */ term) => term.length > 0);
|
||||
|
||||
// Filter recipes by text
|
||||
const matched = filteredByNonText.filter((/** @type {any} */ recipe) => {
|
||||
// Build searchable string from recipe data
|
||||
const searchString = [
|
||||
recipe.name || '',
|
||||
recipe.description || '',
|
||||
...(recipe.tags || [])
|
||||
].join(' ')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.replace(/­|/g, ''); // Remove soft hyphens
|
||||
|
||||
// All search terms must match
|
||||
const searchString = searchStringFor(recipe);
|
||||
return searchTerms.every((/** @type {string} */ term) => searchString.includes(term));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
type CardType = 'supercard' | 'cumulus' | null;
|
||||
|
||||
let { card = $bindable(null), hasSupercard = false, hasCumulus = false } = $props<{
|
||||
card?: CardType;
|
||||
hasSupercard?: boolean;
|
||||
hasCumulus?: boolean;
|
||||
}>();
|
||||
|
||||
function close() { card = null; }
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
|
||||
const showSupercard = $derived(card === 'supercard' && hasSupercard);
|
||||
const showCumulus = $derived(card === 'cumulus' && hasCumulus);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if card}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="backdrop" onclick={close}>
|
||||
<div
|
||||
class="modal"
|
||||
class:is-supercard={showSupercard}
|
||||
class:is-cumulus={showCumulus}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={showSupercard ? 'Coop Supercard' : 'Migros Cumulus'}
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button class="close-button" onclick={close} aria-label="Schliessen">
|
||||
<X />
|
||||
</button>
|
||||
|
||||
{#if showSupercard}
|
||||
<div class="brand-head">
|
||||
<span class="brand">SUPERCARD</span>
|
||||
<span class="sub">Coop</span>
|
||||
</div>
|
||||
<div class="barcode barcode-square">
|
||||
<img src="/shopping/supercard.svg" alt="Supercard Data Matrix" />
|
||||
</div>
|
||||
{:else if showCumulus}
|
||||
<div class="brand-head">
|
||||
<span class="brand">CUMULUS</span>
|
||||
<span class="sub">Migros</span>
|
||||
</div>
|
||||
<div class="barcode barcode-linear">
|
||||
<img src="/shopping/cumulus.svg" alt="Cumulus barcode" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
border-radius: 24px;
|
||||
padding: 1.5rem 1.25rem 1.25rem;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
color: white;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.modal.is-supercard {
|
||||
background: linear-gradient(135deg, #0a6fc2 0%, #055a9e 100%);
|
||||
}
|
||||
.modal.is-cumulus {
|
||||
background: linear-gradient(135deg, #ff6a00 0%, #e55300 100%);
|
||||
}
|
||||
|
||||
/* Red cross button — same pattern as BibleModal */
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
right: -1rem;
|
||||
background-color: var(--nord11);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-pill);
|
||||
color: white;
|
||||
transition: var(--transition-normal);
|
||||
box-shadow: 0 0 1em 0.2em rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
.close-button :global(svg) {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
.close-button:hover {
|
||||
background-color: var(--nord0);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 1em 0.4em rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.close-button:active {
|
||||
transition: 50ms;
|
||||
scale: 0.9 0.9;
|
||||
}
|
||||
|
||||
.brand-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.barcode {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.barcode img {
|
||||
display: block;
|
||||
image-rendering: pixelated; /* crisp barcode modules at any scale */
|
||||
}
|
||||
.barcode-square img {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
.barcode-linear img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 140px;
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.backdrop { padding: 0.5rem; }
|
||||
.modal { padding: 1.25rem 1rem 1rem; border-radius: 20px; }
|
||||
.brand { font-size: 1.25rem; }
|
||||
.barcode { padding: 0.75rem; }
|
||||
.barcode-square img { max-width: none; }
|
||||
.barcode-linear img { min-height: 160px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import { getStickerById } from '$lib/utils/stickers';
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<script>
|
||||
import { X, Sparkles, Wind, Bath, UtensilsCrossed, CookingPot, WashingMachine,
|
||||
Flower2, Droplets, Leaf, ShoppingCart, Trash2, Shirt, Brush } from '@lucide/svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import Wind from '@lucide/svelte/icons/wind';
|
||||
import Bath from '@lucide/svelte/icons/bath';
|
||||
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
|
||||
import CookingPot from '@lucide/svelte/icons/cooking-pot';
|
||||
import WashingMachine from '@lucide/svelte/icons/washing-machine';
|
||||
import Flower2 from '@lucide/svelte/icons/flower-2';
|
||||
import Droplets from '@lucide/svelte/icons/droplets';
|
||||
import Leaf from '@lucide/svelte/icons/leaf';
|
||||
import ShoppingCart from '@lucide/svelte/icons/shopping-cart';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Shirt from '@lucide/svelte/icons/shirt';
|
||||
import Brush from '@lucide/svelte/icons/brush';
|
||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import DatePicker from '$lib/components/DatePicker.svelte';
|
||||
|
||||
@@ -23,7 +23,7 @@ export const BODY_PART_CARDS: BodyPartCard[] = [
|
||||
{ key: 'chest', slugDe: 'brust', labelKey: 'chest', img: 'shoulders.png', paired: false, db: 'chest' },
|
||||
{ key: 'biceps', slugDe: 'bizeps', labelKey: 'biceps', img: 'bicep.png', paired: true, dbLeft: 'leftBicep', dbRight: 'rightBicep' },
|
||||
{ key: 'forearms', slugDe: 'unterarme', labelKey: 'forearms', img: 'forearm.svg', paired: true, dbLeft: 'leftForearm', dbRight: 'rightForearm' },
|
||||
{ key: 'waist', slugDe: 'taille', labelKey: 'waist', img: 'waist.png', paired: false, db: 'waist' },
|
||||
{ key: 'waist', slugDe: 'taille', labelKey: 'waist', img: 'waist.svg', paired: false, db: 'waist' },
|
||||
{ key: 'hips', slugDe: 'huefte', labelKey: 'hips', img: 'hips.png', paired: false, db: 'hips' },
|
||||
{ key: 'thighs', slugDe: 'oberschenkel', labelKey: 'thighs', img: 'thigh.svg', paired: true, dbLeft: 'leftThigh', dbRight: 'rightThigh' },
|
||||
{ key: 'calves', slugDe: 'waden', labelKey: 'calves', img: 'calves.png', paired: true, dbLeft: 'leftCalf', dbRight: 'rightCalf' }
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Session } from '@auth/sveltekit';
|
||||
type BriefRecipeWithFavorite = BriefRecipeType & { isFavorite: boolean };
|
||||
|
||||
export async function getUserFavorites(fetch: typeof globalThis.fetch, locals: App.Locals, recipeLang = 'rezepte'): Promise<string[]> {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
return [];
|
||||
@@ -47,10 +47,10 @@ export async function loadRecipesWithFavorites(
|
||||
recipeLoader: () => Promise<BriefRecipeType[]>,
|
||||
recipeLang = 'rezepte'
|
||||
): Promise<{ recipes: BriefRecipeWithFavorite[], session: Session | null }> {
|
||||
const [recipes, userFavorites, session] = await Promise.all([
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const [recipes, userFavorites] = await Promise.all([
|
||||
recipeLoader(),
|
||||
getUserFavorites(fetch, locals, recipeLang),
|
||||
locals.auth()
|
||||
getUserFavorites(fetch, locals, recipeLang)
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface AuthenticatedUser {
|
||||
export async function requireAuth(
|
||||
locals: RequestEvent['locals']
|
||||
): Promise<AuthenticatedUser> {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session || !session.user?.nickname) {
|
||||
throw json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -53,7 +53,7 @@ export async function requireGroup(
|
||||
locals: RequestEvent['locals'],
|
||||
group: string
|
||||
): Promise<AuthenticatedUser> {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session || !session.user?.nickname) {
|
||||
throw json({ error: 'Unauthorized' }, { status: 401 });
|
||||
@@ -92,7 +92,7 @@ export async function requireGroup(
|
||||
export async function optionalAuth(
|
||||
locals: RequestEvent['locals']
|
||||
): Promise<AuthenticatedUser | null> {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session || !session.user?.nickname) {
|
||||
return null;
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function getShoppingUser(
|
||||
url: URL
|
||||
): Promise<string | null> {
|
||||
// Check session first
|
||||
const auth = await locals.auth();
|
||||
const auth = locals.session ?? await locals.auth();
|
||||
if (auth?.user?.nickname) return auth.user.nickname;
|
||||
|
||||
// Check share token
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Actions, PageServerLoad } from "./$types"
|
||||
import { error } from "@sveltejs/kit"
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const user = session?.user ?? null;
|
||||
return {user}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import Wallet from '@lucide/svelte/icons/wallet';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import ShoppingCart from '@lucide/svelte/icons/shopping-cart';
|
||||
import { detectCospendLang, cospendRoot, cospendLabels } from '$lib/js/cospendI18n';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { errorWithVerse } from '$lib/server/errorQuote';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch, url }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolveStaticAsset } from '$lib/server/staticAsset';
|
||||
import { getShoppingUser } from '$lib/server/shoppingAuth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { ShoppingList, type IShoppingItem } from '$models/ShoppingList';
|
||||
@@ -12,8 +14,15 @@ function serializeItems(items: IShoppingItem[]): ShoppingItem[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function loyaltyCards() {
|
||||
return {
|
||||
hasSupercard: existsSync(resolveStaticAsset('shopping/supercard.svg')),
|
||||
hasCumulus: existsSync(resolveStaticAsset('shopping/cumulus.svg'))
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
// Allow access with valid share token even without session
|
||||
@@ -25,7 +34,8 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
return {
|
||||
session: null,
|
||||
shareToken: token,
|
||||
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }
|
||||
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] },
|
||||
loyalty: loyaltyCards()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,6 +47,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
return {
|
||||
session,
|
||||
shareToken: null,
|
||||
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }
|
||||
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] },
|
||||
loyalty: loyaltyCards()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,22 @@
|
||||
import { onMount, onDestroy, untrack } from 'svelte';
|
||||
import { getShoppingSync } from '$lib/js/shoppingSync.svelte';
|
||||
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
|
||||
import { Plus, ListX, Apple, Beef, Milk, Croissant, Wheat, FlameKindling, GlassWater, Candy, Snowflake, SprayCan, Sparkles, Package, Search, Store } from '@lucide/svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import ListX from '@lucide/svelte/icons/list-x';
|
||||
import Apple from '@lucide/svelte/icons/apple';
|
||||
import Beef from '@lucide/svelte/icons/beef';
|
||||
import Milk from '@lucide/svelte/icons/milk';
|
||||
import Croissant from '@lucide/svelte/icons/croissant';
|
||||
import Wheat from '@lucide/svelte/icons/wheat';
|
||||
import FlameKindling from '@lucide/svelte/icons/flame-kindling';
|
||||
import GlassWater from '@lucide/svelte/icons/glass-water';
|
||||
import Candy from '@lucide/svelte/icons/candy';
|
||||
import Snowflake from '@lucide/svelte/icons/snowflake';
|
||||
import SprayCan from '@lucide/svelte/icons/spray-can';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import Package from '@lucide/svelte/icons/package';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Store from '@lucide/svelte/icons/store';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { slide } from 'svelte/transition';
|
||||
@@ -10,7 +25,15 @@
|
||||
import catalogData from '$lib/data/shoppingCatalog.json';
|
||||
import iconCategoriesData from '$lib/data/shoppingIconCategories.json';
|
||||
|
||||
import { Share2, X, Copy, Check } from '@lucide/svelte';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import CreditCard from '@lucide/svelte/icons/credit-card';
|
||||
import LoyaltyCards from '$lib/components/shopping/LoyaltyCards.svelte';
|
||||
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import { page } from '$app/stores';
|
||||
import { detectCospendLang, t, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions } from '$lib/js/cospendI18n';
|
||||
|
||||
@@ -272,6 +295,12 @@
|
||||
editSaving = false;
|
||||
}
|
||||
|
||||
// --- Loyalty cards ---
|
||||
/** @type {'supercard' | 'cumulus' | null} */
|
||||
let activeCard = $state(null);
|
||||
const hasSupercard = $derived(!!data.loyalty?.hasSupercard);
|
||||
const hasCumulus = $derived(!!data.loyalty?.hasCumulus);
|
||||
|
||||
// --- Share links ---
|
||||
let showShareModal = $state(false);
|
||||
/** @type {{ id: string, token: string, expiresAt: string, createdBy: string, createdAt: string }[]} */
|
||||
@@ -400,6 +429,16 @@
|
||||
<div class="header-row">
|
||||
<h1 class="sr-only">{t('shopping_list_title', lang)}</h1>
|
||||
<SyncIndicator status={sync.status} />
|
||||
{#if hasSupercard}
|
||||
<button class="btn-card btn-card-coop" onclick={() => activeCard = 'supercard'} title="Coop Supercard" aria-label="Coop Supercard">
|
||||
<CreditCard size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasCumulus}
|
||||
<button class="btn-card btn-card-migros" onclick={() => activeCard = 'cumulus'} title="Migros Cumulus" aria-label="Migros Cumulus">
|
||||
<CreditCard size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isGuest}
|
||||
<button class="btn-share" onclick={openShareModal} title={t('share', lang)}>
|
||||
<Share2 size={16} />
|
||||
@@ -496,6 +535,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<LoyaltyCards bind:card={activeCard} {hasSupercard} {hasCumulus} />
|
||||
|
||||
{#if editingItem}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -669,6 +710,31 @@
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.btn-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, filter 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.btn-card:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.btn-card:active { transform: translateY(0); filter: brightness(0.95); }
|
||||
.btn-card-coop {
|
||||
background: linear-gradient(135deg, #0a6fc2 0%, #055a9e 100%);
|
||||
}
|
||||
.btn-card-migros {
|
||||
background: linear-gradient(135deg, #ff6a00 0%, #e55300 100%);
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { errorWithVerse } from '$lib/server/errorQuote';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch, url }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect, fail } from '@sveltejs/kit';
|
||||
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
@@ -18,7 +18,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals, fetch, params }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session || !session.user?.nickname) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { errorWithVerse } from '$lib/server/errorQuote';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, fetch, url }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PageServerLoad, Actions } from './$types';
|
||||
import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals, request, url }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
|
||||
+21
-8
@@ -13,16 +13,17 @@ import {
|
||||
type Diocese1969,
|
||||
type Rite
|
||||
} from '../../../../calendarI18n';
|
||||
import { seasonColorFor } from '../../../../calendarColors';
|
||||
import { rankDotSize, seasonColorFor } from '../../../../calendarColors';
|
||||
import {
|
||||
getYear,
|
||||
getYear1962,
|
||||
isoFor
|
||||
} from '$lib/server/liturgicalCalendar';
|
||||
import type { CalendarDay, SeasonArc, YearDay } from '$lib/calendarTypes';
|
||||
import type { CalendarDay, FeastDot, SeasonArc, YearDay } from '$lib/calendarTypes';
|
||||
|
||||
export type {
|
||||
CalendarDay,
|
||||
FeastDot,
|
||||
ProperSection,
|
||||
Rite1962Commem,
|
||||
Rite1962Detail,
|
||||
@@ -234,14 +235,25 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const yearDays: YearDay[] = sortedYear.map((d, i) => ({
|
||||
// `yearDays` only carries what the ring's needle-color lookup needs for any
|
||||
// day (feast or ferial). Feast metadata (name, rank) moves into `feastDots`
|
||||
// below so the client can iterate it directly without filtering 365 entries.
|
||||
const yearDays: YearDay[] = sortedYear.map((d) => ({
|
||||
iso: d.iso,
|
||||
name: d.name,
|
||||
rank: d.rank,
|
||||
color: d.colorKeys[0] ?? 'GREEN',
|
||||
seasonKey: filledSeasons[i]
|
||||
color: d.colorKeys[0] ?? 'GREEN'
|
||||
}));
|
||||
|
||||
const feastDots: FeastDot[] = [];
|
||||
for (const d of sortedYear) {
|
||||
if (rankDotSize(d.rank) === 0) continue;
|
||||
feastDots.push({
|
||||
iso: d.iso,
|
||||
name: d.name,
|
||||
rank: d.rank,
|
||||
color: d.colorKeys[0] ?? 'GREEN'
|
||||
});
|
||||
}
|
||||
|
||||
const seasonArcs: SeasonArc[] = [];
|
||||
let cur: SeasonArc | null = null;
|
||||
for (let i = 0; i < sortedYear.length; i++) {
|
||||
@@ -282,6 +294,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
|
||||
month,
|
||||
monthDays,
|
||||
yearDays,
|
||||
feastDots,
|
||||
seasonArcs,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
@@ -291,6 +304,6 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
|
||||
todayIso,
|
||||
selected: selectedEntry,
|
||||
selectedIso,
|
||||
session: locals.session ?? (await locals.auth())
|
||||
session: locals.session ?? await locals.auth()
|
||||
};
|
||||
};
|
||||
|
||||
+2
@@ -28,6 +28,7 @@
|
||||
const month = $derived(data.month);
|
||||
const monthDays = $derived(data.monthDays);
|
||||
const yearDays = $derived(data.yearDays);
|
||||
const feastDots = $derived(data.feastDots);
|
||||
const seasonArcs = $derived(data.seasonArcs);
|
||||
const today = $derived(data.today);
|
||||
const todayIso = $derived(data.todayIso);
|
||||
@@ -267,6 +268,7 @@
|
||||
{year}
|
||||
{liturgicalYear}
|
||||
{yearDays}
|
||||
{feastDots}
|
||||
{seasonArcs}
|
||||
{todayIso}
|
||||
{selectedIso}
|
||||
|
||||
+9
-20
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { YearDay, SeasonArc } from './+page.server';
|
||||
import type { FeastDot, YearDay, SeasonArc } from './+page.server';
|
||||
import type { CalendarLang } from '../../../../calendarI18n';
|
||||
import { litBg, litInk, rankDotSize } from '../../../../calendarColors';
|
||||
import { Tween, prefersReducedMotion } from 'svelte/motion';
|
||||
@@ -11,6 +11,7 @@
|
||||
year,
|
||||
liturgicalYear,
|
||||
yearDays,
|
||||
feastDots: feastDotsProp,
|
||||
seasonArcs,
|
||||
todayIso,
|
||||
selectedIso = null,
|
||||
@@ -25,6 +26,7 @@
|
||||
year: number;
|
||||
liturgicalYear: number;
|
||||
yearDays: YearDay[];
|
||||
feastDots: FeastDot[];
|
||||
seasonArcs: SeasonArc[];
|
||||
todayIso: string;
|
||||
selectedIso?: string | null;
|
||||
@@ -161,20 +163,10 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
// Feast dots: keep only the highest-ranking feast per ISO date, skip ferias.
|
||||
// The currently-selected feast is omitted because the static needle pin at
|
||||
// the top of the ring represents it.
|
||||
const feastDots = $derived.by(() => {
|
||||
const byDate = new Map<string, YearDay>();
|
||||
for (const d of yearDays) {
|
||||
const size = rankDotSize(d.rank);
|
||||
if (size === 0) continue;
|
||||
if (d.iso === needleIso) continue;
|
||||
const cur = byDate.get(d.iso);
|
||||
if (!cur || rankDotSize(d.rank) > rankDotSize(cur.rank)) byDate.set(d.iso, d);
|
||||
}
|
||||
return [...byDate.values()];
|
||||
});
|
||||
// Feast dots come pre-filtered from the server (rank > ferial, one per ISO).
|
||||
// Only strip the currently-selected day here since the needle pin at the top
|
||||
// already represents it.
|
||||
const feastDots = $derived(feastDotsProp.filter((d) => d.iso !== needleIso));
|
||||
|
||||
// A season can split into multiple arcs within one gregorian year (e.g.
|
||||
// ChristmasTide spans both Dec 25–31 and Jan 1–13 of the civil year). Each
|
||||
@@ -206,11 +198,8 @@
|
||||
);
|
||||
|
||||
const activeFeasts = $derived.by(() => {
|
||||
if (!active) return [] as YearDay[];
|
||||
return yearDays.filter(
|
||||
(d) =>
|
||||
rankDotSize(d.rank) > 0 && d.iso >= active.start && d.iso <= active.end
|
||||
);
|
||||
if (!active) return [] as FeastDot[];
|
||||
return feastDotsProp.filter((d) => d.iso >= active.start && d.iso <= active.end);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
+1
-1
@@ -96,6 +96,6 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
|
||||
iso,
|
||||
todayIso,
|
||||
day1: entry,
|
||||
session: locals.session ?? (await locals.auth())
|
||||
session: locals.session ?? await locals.auth()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
|
||||
|
||||
// Fetch angelus streak data for angelus/regina-caeli pages
|
||||
if (angelusSlugs.has(params.prayer)) {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
if (session?.user?.nickname) {
|
||||
try {
|
||||
const res = await fetch('/api/glaube/angelus-streak');
|
||||
@@ -54,7 +54,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
'pray-angelus': async ({ request, locals, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function getMysteryForWeekday(date: Date, includeLuminous: boolean): string {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
// Read toggle/mystery state from URL search params (for no-JS progressive enhancement)
|
||||
const luminousParam = url.searchParams.get('luminous');
|
||||
@@ -105,7 +105,7 @@ export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
pray: async ({ locals, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import MysteryImageColumn from "./MysteryImageColumn.svelte";
|
||||
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, mysteryTitlesLatin, allMysteryImages, getLabels, getLabelsLatin, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
|
||||
import { isEastertide, getLiturgicalSeason } from "$lib/js/easter.svelte";
|
||||
import { setupScrollSync } from "./rosaryScrollSync.js";
|
||||
import { BookOpen } from "@lucide/svelte";
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
let { data } = $props();
|
||||
|
||||
// Toggle for including Luminous mysteries (initialized from URL param or default)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { ArrowDown, ArrowLeft } from '@lucide/svelte';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import { page } from '$app/stores';
|
||||
/** @type {number | string | null} */
|
||||
let expanded = $state(null);
|
||||
|
||||
@@ -45,7 +45,12 @@ onNavigate((navigation) => {
|
||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';
|
||||
import { BookOpen, Heart, Leaf, LayoutGrid, Palette, Tag } from '@lucide/svelte';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import Leaf from '@lucide/svelte/icons/leaf';
|
||||
import LayoutGrid from '@lucide/svelte/icons/layout-grid';
|
||||
import Palette from '@lucide/svelte/icons/palette';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
let { data, children } = $props();
|
||||
|
||||
let user = $derived(data.session?.user);
|
||||
|
||||
@@ -4,12 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
// Fetch all_brief, favorites, and session in parallel
|
||||
const [res_all_brief, userFavorites, session] = await Promise.all([
|
||||
const [res_all_brief, userFavorites] = await Promise.all([
|
||||
fetch(`${apiBase}/items/all_brief`).then(r => r.json()),
|
||||
getUserFavorites(fetch, locals, params.recipeLang),
|
||||
locals.auth()
|
||||
getUserFavorites(fetch, locals, params.recipeLang)
|
||||
]);
|
||||
|
||||
const all_brief = addFavoriteStatusToRecipes(res_all_brief, userFavorites);
|
||||
|
||||
@@ -19,8 +19,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals, url }) => {
|
||||
const strippedName = stripHtmlTags(item.name);
|
||||
const strippedDescription = stripHtmlTags(item.description);
|
||||
|
||||
// Get session for user info
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
return {
|
||||
item,
|
||||
@@ -34,7 +33,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals, url }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
toggleFavorite: async ({ request, locals, url, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
|
||||
@@ -16,7 +16,7 @@ export const load: PageServerLoad = async ({locals, params}) => {
|
||||
throw redirect(301, '/rezepte/add');
|
||||
}
|
||||
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
return {
|
||||
user: session?.user
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export const load: PageServerLoad = async ({locals, params}) => {
|
||||
export const actions = {
|
||||
default: async ({ request, locals, params }) => {
|
||||
// Check authentication
|
||||
const auth = await locals.auth();
|
||||
const auth = locals.session ?? await locals.auth();
|
||||
if (!auth) {
|
||||
return fail(401, {
|
||||
error: 'You must be logged in to add recipes',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { errorWithVerse } from '$lib/server/errorQuote';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!session?.user?.nickname) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { errorWithVerse } from '$lib/server/errorQuote';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
const callbackUrl = encodeURIComponent(url.pathname);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { errorWithVerse } from '$lib/server/errorQuote';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals, url, params }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!session?.user?.nickname) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { errorWithVerse } from '$lib/server/errorQuote';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, url, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!session?.user?.nickname) {
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
|
||||
const [res, allRes, userFavorites, session] = await Promise.all([
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const [res, allRes, userFavorites] = await Promise.all([
|
||||
fetch(`${apiBase}/items/category/${params.category}`),
|
||||
fetch(`${apiBase}/items/all_brief`),
|
||||
getUserFavorites(fetch, locals, params.recipeLang),
|
||||
locals.auth()
|
||||
getUserFavorites(fetch, locals, params.recipeLang)
|
||||
]);
|
||||
|
||||
const [items, allRecipes] = await Promise.all([res.json(), allRes.json()]);
|
||||
|
||||
@@ -68,7 +68,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals}) => {
|
||||
|
||||
const apiRes = await fetch(`/api/rezepte/items/${params.name}`);
|
||||
const recipe = await apiRes.json();
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
return {
|
||||
recipe: recipe,
|
||||
user: session?.user
|
||||
@@ -78,7 +78,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals}) => {
|
||||
export const actions = {
|
||||
default: async ({ request, locals, params }) => {
|
||||
// Check authentication
|
||||
const auth = await locals.auth();
|
||||
const auth = locals.session ?? await locals.auth();
|
||||
if (!auth) {
|
||||
return fail(401, {
|
||||
error: 'You must be logged in to edit recipes',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
const callbackUrl = encodeURIComponent(`/${params.recipeLang}/favorites`);
|
||||
@@ -11,44 +11,25 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const [res, allRes] = await Promise.all([
|
||||
fetch(`${apiBase}/favorites/recipes`),
|
||||
fetch(`${apiBase}/items/all_brief`)
|
||||
]);
|
||||
const res = await fetch(`${apiBase}/favorites/recipes`);
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
favorites: [],
|
||||
allRecipes: [],
|
||||
error: 'Failed to load favorites'
|
||||
};
|
||||
}
|
||||
|
||||
const [favorites, allRecipes] = await Promise.all([res.json(), allRes.json()]);
|
||||
|
||||
// Mark all favorites with isFavorite flag for filter compatibility
|
||||
const favoritesWithFlag = favorites.map((recipe: any) => ({
|
||||
...recipe,
|
||||
isFavorite: true
|
||||
}));
|
||||
|
||||
// Get favorite IDs for marking in allRecipes
|
||||
const favoriteIds = new Set(favoritesWithFlag.map((r: any) => r._id));
|
||||
const allRecipesWithFavorites = allRecipes.map((recipe: any) => ({
|
||||
...recipe,
|
||||
isFavorite: favoriteIds.has(recipe._id)
|
||||
}));
|
||||
const favorites = await res.json();
|
||||
|
||||
return {
|
||||
favorites: favoritesWithFlag,
|
||||
allRecipes: allRecipesWithFavorites,
|
||||
favorites: favorites.map((recipe: any) => ({ ...recipe, isFavorite: true })),
|
||||
session
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
favorites: [],
|
||||
allRecipes: [],
|
||||
error: 'Failed to load favorites'
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
|
||||
function handleSearchResults(ids: Set<string>, categories: Set<string>) {
|
||||
matchedRecipeIds = ids;
|
||||
hasActiveSearch = ids.size < (data.allRecipes?.length || data.favorites.length);
|
||||
hasActiveSearch = ids.size < data.favorites.length;
|
||||
}
|
||||
|
||||
const filteredFavorites = $derived.by(() => {
|
||||
if (!hasActiveSearch) return data.favorites;
|
||||
return data.allRecipes.filter((r: any) => matchedRecipeIds.has(r._id));
|
||||
return data.favorites.filter((r: any) => matchedRecipeIds.has(r._id));
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<p class="to-try-link"><a href="/{data.recipeLang}/to-try">{labels.toTry} →</a></p>
|
||||
|
||||
<Search favoritesOnly={true} lang={data.lang} recipes={data.allRecipes || data.favorites} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
|
||||
<Search favoritesOnly={true} lang={data.lang} recipes={data.favorites} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
|
||||
|
||||
{#if data.error}
|
||||
<p class="empty-state">{labels.errorLoading} {data.error}</p>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
|
||||
const [item_season, icons, userFavorites, session] = await Promise.all([
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const [item_season, icons, userFavorites] = await Promise.all([
|
||||
fetch(`${apiBase}/items/icon/` + params.icon).then(r => r.json()),
|
||||
fetch(`${apiBase}/items/icon`).then(r => r.json()),
|
||||
getUserFavorites(fetch, locals, params.recipeLang),
|
||||
locals.auth()
|
||||
getUserFavorites(fetch, locals, params.recipeLang)
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,11 +8,8 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const res_season = await fetch(`${apiBase}/items/in_season/` + current_month);
|
||||
const item_season = await res_season.json();
|
||||
|
||||
// Get user favorites and session
|
||||
const [userFavorites, session] = await Promise.all([
|
||||
getUserFavorites(fetch, locals, params.recipeLang),
|
||||
locals.auth()
|
||||
]);
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const userFavorites = await getUserFavorites(fetch, locals, params.recipeLang);
|
||||
|
||||
return {
|
||||
season: addFavoriteStatusToRecipes(item_season, userFavorites),
|
||||
|
||||
@@ -7,11 +7,8 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const res_season = await fetch(`${apiBase}/items/in_season/` + params.month);
|
||||
const item_season = await res_season.json();
|
||||
|
||||
// Get user favorites and session
|
||||
const [userFavorites, session] = await Promise.all([
|
||||
getUserFavorites(fetch, locals, params.recipeLang),
|
||||
locals.auth()
|
||||
]);
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const userFavorites = await getUserFavorites(fetch, locals, params.recipeLang);
|
||||
|
||||
return {
|
||||
month: params.month,
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
|
||||
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
|
||||
const apiBase = `/api/${params.recipeLang}`;
|
||||
|
||||
const [res_tag, allRes, userFavorites, session] = await Promise.all([
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const [res_tag, allRes, userFavorites] = await Promise.all([
|
||||
fetch(`${apiBase}/items/tag/${params.tag}`),
|
||||
fetch(`${apiBase}/items/all_brief`),
|
||||
getUserFavorites(fetch, locals, params.recipeLang),
|
||||
locals.auth()
|
||||
getUserFavorites(fetch, locals, params.recipeLang)
|
||||
]);
|
||||
|
||||
const [items_tag, allRecipes] = await Promise.all([res_tag.json(), allRes.json()]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ToTryRecipe } from '$models/ToTryRecipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user) {
|
||||
const callbackUrl = encodeURIComponent(`/${params.recipeLang}/to-try`);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { error } from '@sveltejs/kit';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
@@ -29,7 +29,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
@@ -67,7 +67,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dbConnect } from '$utils/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
return json({ isFavorite: false });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { error } from '@sveltejs/kit';
|
||||
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Authentication required');
|
||||
@@ -65,10 +65,10 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
germanShortName: recipe.short_name,
|
||||
translationStatus: t?.translationStatus
|
||||
}});
|
||||
return json(JSON.parse(JSON.stringify(englishRecipes)));
|
||||
return json(englishRecipes);
|
||||
}
|
||||
|
||||
return json(JSON.parse(JSON.stringify(recipes)));
|
||||
return json(recipes);
|
||||
} catch (e) {
|
||||
throw error(500, 'Failed to fetch favorite recipes');
|
||||
}
|
||||
|
||||
@@ -42,10 +42,15 @@ function resolveNutritionData(mappings: any[]): any[] {
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
await dbConnect();
|
||||
const en = isEnglish(params.recipeLang!);
|
||||
|
||||
// Individual recipes change when the author edits them. 5 min browser + 1 h
|
||||
// edge cache with SWR lets proxies keep hot recipes fresh without blocking
|
||||
// on the DB; stale content beyond max-age is tolerable here.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
|
||||
|
||||
const query = en
|
||||
? { 'translations.en.short_name': params.name }
|
||||
: { short_name: params.name };
|
||||
|
||||
@@ -4,12 +4,19 @@ import { dbConnect } from '$utils/db';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
|
||||
|
||||
await dbConnect();
|
||||
const dbRecipes = await Recipe.find(approvalFilter, projection).lean();
|
||||
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
|
||||
|
||||
return json(JSON.parse(JSON.stringify(rand_array(recipes))));
|
||||
// rand_array is seeded by `floor(now / 86400000)` — stable for a full UTC
|
||||
// day across every caller — so the response is safe to share. 8 h browser +
|
||||
// 8 h edge cache means at worst the shuffle rolls into the next day a few
|
||||
// hours late; with SWR the stale payload still ships while a fresh one is
|
||||
// computed.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
|
||||
|
||||
return json(rand_array(recipes));
|
||||
};
|
||||
|
||||
@@ -3,12 +3,17 @@ import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const { approvalFilter, prefix } = briefQueryConfig(params.recipeLang!);
|
||||
await dbConnect();
|
||||
|
||||
const field = `${prefix}category`;
|
||||
const categories = await Recipe.distinct(field, approvalFilter).lean();
|
||||
|
||||
return json(JSON.parse(JSON.stringify(categories)));
|
||||
// Distinct category list changes only on recipe add/edit. 1 h browser cache +
|
||||
// 1 d edge cache with SWR keeps the chip bar snappy; worst case a newly
|
||||
// added category shows up an hour late.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
|
||||
|
||||
return json(categories);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const { approvalFilter, prefix, projection } = briefQueryConfig(params.recipeLang!);
|
||||
await dbConnect();
|
||||
|
||||
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
).lean();
|
||||
|
||||
const recipes = rand_array(dbRecipes.map(r => toBrief(r, params.recipeLang!)));
|
||||
return json(JSON.parse(JSON.stringify(recipes)));
|
||||
// rand_array is seeded per UTC day, same for every caller → cacheable.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
|
||||
return json(recipes);
|
||||
};
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import type {BriefRecipeType} from '$types/types';
|
||||
|
||||
export const GET: RequestHandler = async ({params}) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
await dbConnect();
|
||||
let icons = (await Recipe.distinct('icon').lean());
|
||||
const icons = await Recipe.distinct('icon').lean();
|
||||
|
||||
// Same cache budget as /items/category.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
|
||||
|
||||
icons = JSON.parse(JSON.stringify(icons));
|
||||
return json(icons);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
|
||||
await dbConnect();
|
||||
|
||||
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
).lean();
|
||||
|
||||
const recipes = rand_array(dbRecipes.map(r => toBrief(r, params.recipeLang!)));
|
||||
return json(JSON.parse(JSON.stringify(recipes)));
|
||||
// rand_array is seeded per UTC day, same for every caller → cacheable.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
|
||||
return json(recipes);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
|
||||
|
||||
await dbConnect();
|
||||
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
).lean();
|
||||
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
|
||||
|
||||
return json(JSON.parse(JSON.stringify(rand_array(recipes))));
|
||||
// rand_array is seeded per UTC day, same for every caller → cacheable.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
|
||||
return json(rand_array(recipes));
|
||||
};
|
||||
|
||||
@@ -3,10 +3,14 @@ import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const { approvalFilter } = briefQueryConfig(params.recipeLang!);
|
||||
await dbConnect();
|
||||
|
||||
// Same cache budget as /items/category — distinct-values list that only
|
||||
// changes on recipe edit.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
|
||||
|
||||
if (isEnglish(params.recipeLang!)) {
|
||||
const recipes = await Recipe.find(approvalFilter, 'translations.en.tags').lean();
|
||||
const tagsSet = new Set<string>();
|
||||
@@ -15,9 +19,9 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
recipe.translations.en.tags.forEach((tag: string) => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
return json(JSON.parse(JSON.stringify(Array.from(tagsSet).sort())));
|
||||
return json(Array.from(tagsSet).sort());
|
||||
}
|
||||
|
||||
const tags = await Recipe.distinct('tags').lean();
|
||||
return json(JSON.parse(JSON.stringify(tags)));
|
||||
return json(tags);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
const { approvalFilter, prefix, projection } = briefQueryConfig(params.recipeLang!);
|
||||
|
||||
await dbConnect();
|
||||
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
).lean();
|
||||
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
|
||||
|
||||
return json(JSON.parse(JSON.stringify(rand_array(recipes))));
|
||||
// rand_array is seeded per UTC day, same for every caller → cacheable.
|
||||
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
|
||||
return json(rand_array(recipes));
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { NutritionMapping } from '$types/types';
|
||||
|
||||
/** PATCH: Update individual nutrition mappings (manual edit UI) */
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
await locals.auth();
|
||||
locals.session ?? await locals.auth();
|
||||
await dbConnect();
|
||||
|
||||
const en = isEnglish(params.recipeLang!);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isEnglish } from '$lib/server/recipeHelpers';
|
||||
import { generateNutritionMappings } from '$lib/server/nutritionMatcher';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals, url }) => {
|
||||
await locals.auth();
|
||||
locals.session ?? await locals.auth();
|
||||
await dbConnect();
|
||||
|
||||
const en = isEnglish(params.recipeLang!);
|
||||
|
||||
@@ -72,8 +72,8 @@ export const GET: RequestHandler = async () => {
|
||||
});
|
||||
|
||||
return json({
|
||||
brief: JSON.parse(JSON.stringify(briefRecipes)),
|
||||
full: JSON.parse(JSON.stringify(processedFullRecipes)),
|
||||
brief: briefRecipes,
|
||||
full: processedFullRecipes,
|
||||
syncedAt: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const GET: RequestHandler = async ({ url, params, locals }) => {
|
||||
let recipes: BriefRecipeType[] = dbRecipes.map(r => toBrief(r, params.recipeLang!));
|
||||
|
||||
// Handle favorites filter
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
if (favoritesOnly && session?.user) {
|
||||
const { UserFavorites } = await import('$models/UserFavorites');
|
||||
const userFavorites = await UserFavorites.findOne({ username: session.user.nickname });
|
||||
@@ -71,7 +71,7 @@ export const GET: RequestHandler = async ({ url, params, locals }) => {
|
||||
});
|
||||
}
|
||||
|
||||
return json(JSON.parse(JSON.stringify(recipes)));
|
||||
return json(recipes);
|
||||
} catch (e) {
|
||||
return json({ error: 'Search failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ToTryRecipe } from '$models/ToTryRecipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.groups?.includes('rezepte_users')) {
|
||||
throw error(403, 'Forbidden');
|
||||
@@ -20,7 +20,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.groups?.includes('rezepte_users')) {
|
||||
throw error(403, 'Forbidden');
|
||||
@@ -51,7 +51,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.groups?.includes('rezepte_users')) {
|
||||
throw error(403, 'Forbidden');
|
||||
@@ -96,7 +96,7 @@ export const PATCH: RequestHandler = async ({ request, locals }) => {
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.groups?.includes('rezepte_users')) {
|
||||
throw error(403, 'Forbidden');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
const session = locals.session ?? await locals.auth();
|
||||
|
||||
if (!session?.user?.nickname) {
|
||||
throw error(401, 'Anmeldung erforderlich');
|
||||
@@ -40,7 +40,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
translationStatus: recipe.translations?.en?.translationStatus || undefined
|
||||
}));
|
||||
|
||||
return json(JSON.parse(JSON.stringify(result)));
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
console.error('Error fetching untranslated recipes:', e);
|
||||
throw error(500, 'Fehler beim Laden der unübersetzten Rezepte');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dbConnect } from '$utils/db';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const auth = await locals.auth();
|
||||
const auth = locals.session ?? await locals.auth();
|
||||
if (!auth || !auth.user?.nickname) {
|
||||
throw error(401, 'Not logged in');
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface DebtSummary {
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const auth = await locals.auth();
|
||||
const auth = locals.session ?? await locals.auth();
|
||||
if (!auth || !auth.user?.nickname) {
|
||||
throw error(401, 'Not logged in');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user