17 Commits

Author SHA1 Message Date
Alexander 2e8685d02b style(recipes): unify custom multiplier pill with preset pills
CI / update (push) Successful in 5m48s
Make the custom-multiplier pill behave and look like a single input zone:

- Wrapper is now a <label> so clicking anywhere focuses the input.
- Replace the explicit \"x\" submit button with a passive <span> suffix and
  add a visually-hidden first-tree-order submit so no-JS Enter still
  submits with the typed value (rather than the first preset pill's value).
- Wrapper cursor: text end-to-end, no pointer flicker.
- Hover/focus selector now matches the wrapper alongside the preset
  buttons, and an isCustomMultiplier flag highlights the pill in primary
  whenever a non-preset value is active (e.g. ?multiplier=12).
- Input uses field-sizing: content (with min/max) so the pill collapses
  to fit the placeholder.
- align-items: center (was baseline) so the input doesn't sit high in
  its pill.
- Tighten the multipliers row (gap 0.5rem -> 0.3rem, button min-width
  2em -> 1.8em, matching paddings) so all six pills fit on one line in
  the ingredients column.
2026-05-01 14:50:13 +02:00
Alexander bcdb9a9c4b refactor(recipes): split base + cake-form multipliers
Cake-form scaling no longer overwrites the base multiplier (pill buttons
+ custom input). Both factors stay independent and compose as
effectiveMultiplier = multiplier * formMultiplier, which feeds ingredient
amounts, portions, nested-recipe links, HefeSwapper, and NutritionSummary.

Pills reflect the base only; the existing cake-form badge keeps showing
the form factor whenever it deviates from 1, so the two contributions
stay visually distinct. Drop the formDriven flag, the effect that wrote
formMultiplier into multiplier, and the now-redundant
oninput=applyFormMultiplier hooks (bind:value already triggers
recomputation). resetCakeForm only resets form fields now.
2026-05-01 14:27:21 +02:00
Alexander dbce9629a5 fix(recipes): coerce season month to string for resolve()
resolve() requires string params; season[0] is a number, which made
param_value.startsWith blow up on /[recipeLang]/[name] pages.
2026-05-01 14:20:24 +02:00
Alexander 79f4dbb101 i18n(common): bootstrap shared namespace + migrate top-level UI
Add a per-locale common dictionary at src/lib/i18n/common/{de,en}.ts and
the shim src/lib/js/commonI18n.ts. Migrate inline lang ternaries on the
homepage (welcome/sections/links), OfflineSyncButton (all label
ternaries), DatePicker (today/select date), ErrorView (Error/Fehler
eyebrow), and UserHeader (login aria/title) to use the shared dict.

The long marketing intro paragraphs on the homepage stay inline since
they're one-shot content with no drift risk and don't benefit from
per-key extraction.

Bump site version to 1.57.0 (new namespace).
2026-05-01 14:03:52 +02:00
Alexander 71f7322624 i18n(fitness): migrate inline ternaries across pages and components
Replace lang === 'en' string ternaries on the check-in, stats, workout,
exercises, history, and stats history detail pages, plus TemplateCard,
with t.<key> lookups against the fitness dictionary. Added new keys for
toast messages, body-part counts, body-fat label, clear/measure short
labels, "edit all fields", BF chart delta prefix, calorie balance and
adherence tooltips, actual/target legend labels, daily expenditure
prefix, height/birth/weight setup hint, exercise/workout/recent labels,
"starts with", and a {n}-template "X days ago" string.

URL slug ternaries (e.g. 'check-in' / 'erfassung') remain inline since
they encode route data, not UI text.

Bump site version to 1.56.2.
2026-05-01 14:01:06 +02:00
Alexander bd9e9b397f i18n(recipes): finish remaining ternaries across components and pages
Migrate FavoritesFilter, IconFilter, TagFilter, FilterPanel, HefeSwapper
and the offline-shell, season/[month], icon/[icon], favorites, search,
tips-and-tricks, and index pages to use the recipes i18n dictionary.
Add corresponding keys for filter toggles, filter placeholders, yeast
toggle title, recipes-growing suffix, search "for" preposition, and
favorites count labels. Strip unused isEnglish derivations from layout,
tag, and category landing pages.

Bump site version to 1.56.1.
2026-05-01 13:54:41 +02:00
Alexander ea1a85e935 i18n(recipes): migrate 13 pages and components
Bulk migration of the recipes namespace following the same pattern as
fitness/cospend/calendar/faith. Layout collapses its label-object into
t.foo lookups; NutritionSummary's 33 ternaries (incl. the
German-stem-plus-optional-e amino-acid pattern that read
`Lysin{isEnglish ? 'e' : ''}`) become straight dictionary references;
AddToFoodLogButton, IngredientsPage, to-try, search, favorites,
the index, and the small landing pages (category, tag, season, icon,
tips-and-tricks) all migrate the same way.

The recipes dict is now ~120 keys. Patterns kept intentionally:

  - Long page-specific marketing copy (subheading sentences, meta
    descriptions that include dynamic counts, hero alt text variants)
    stays inline as `lang === 'en' ? '...' : '...'` rather than
    bloating the dict with one-shot strings.
  - URL slug ternaries stay inline — those are URL data, not UI text.
  - The `recipes/admin/nutrition` page was deliberately skipped — admin
    tooling, ~18 ternaries that are mostly admin-jargon strings used
    in exactly one place.

Detail pages ([name]/+page, [name]/+error, IngredientsPage extras,
InstructionsPage, smaller components) and the admin page remain for
follow-up commits.
2026-05-01 13:34:44 +02:00
Alexander d540b82e85 i18n(recipes): bootstrap namespace + migrate layout, NutritionSummary
Two-locale recipes dictionary lands at src/lib/i18n/recipes/{de,en}.ts
with the same satisfies-based completeness enforcement as the other
namespaces. recipesI18n.ts is the slim shim — exports m, RecipesLang,
RecipesKey, plus langFromRecipeSlug / recipeSlugFromLang helpers for
the rezepte ↔ recipes URL slug mapping.

[recipeLang]/+layout.svelte's nav-label ternary chain collapses into
t.foo lookups. NutritionSummary.svelte is the heavy hitter — 33
inline isEnglish ternaries become a single dictionary load. Most
amino-acid names use a German-stem-plus-optional-e pattern in the old
code (`Lysin{isEnglish ? 'e' : ''}`) that's now just t.lysine in the
template; less clever, much more obviously translatable.
2026-05-01 13:22:59 +02:00
Alexander d7f96f35c2 i18n(faith): migrate prayers index + prayer detail
Adds prayer-name keys (sign_of_cross, pater_noster, fatima_prayer, …),
search/filter UI labels (search_prayers, clear_search, filter_by_category,
all_categories), the eastertide_badge, and the prayer-detail-only
nicene_creed / hail_mary aliases (German + Latin keep the Latin form,
English uses the English name).

Prayers index labels object collapses each name ternary into a t.foo
lookup; the language-invariant ones (Glória Patri, Credo, Ave Maria,
Salve Regina, Glória, Ánima Christi, Tantum Ergo, Angelus, Regína Cæli)
stay hardcoded as single strings since they're identical across all
three locales. The baseUrl building now uses faithSlugFromLang/prayersSlug
helpers instead of inline ternaries.

Prayer detail's prayerDefs routing table — every name field that was
isEnglish ? a : b now points at a t.* lookup. Painting captions for
the Velázquez/Murillo Angelus/Regina Cæli backgrounds become
t.painting_coronation_virgin / t.painting_annunciation. The
AngelusStreakCounter call site drops its three-way ternary in favor of
the typed `lang` derived value.

Slug-table ternaries (URL slug per locale) and the long gloriaIntro
paragraph are intentionally left inline — slugs are URL data, not UI
text, and gloriaIntro is page-unique marketing copy that doesn't
benefit from being in a shared dict.
2026-05-01 13:16:47 +02:00
Alexander 3dcb5c7f2b i18n(faith): migrate streak components, BibleModal, katechese notices
Adds streak/angelus and Bible-modal keys to the faith dictionary, plus
the three-fragment "this catechesis is only available in German" notice
used by both katechese pages. Pluralization for day/days handled by two
explicit keys (day_singular/day_plural) chosen at the call site —
Latin's "Dies" is invariant so both keys hold the same string.

StreakCounter and AngelusStreakCounter collapse their per-component
labels objects into direct t.foo lookups; the rosary page's BibleModal
call site now passes the typed `lang` derived value (was data.lang as
plain string, didn't satisfy the tightened FaithLang prop type).

BibleModal isn't actually used in Latin context, but the dict requires
every key in every locale, so reasonable Latin equivalents got filled
in for completeness.
2026-05-01 13:10:29 +02:00
Alexander 28b96a8dc0 feat(i18n): bootstrap faith namespace + migrate layout, homepage, apologetik
Three-locale faith dictionary lands at src/lib/i18n/faith/{de,en,la}.ts
with the same satisfies-based completeness enforcement we use for
fitness, cospend, and calendar. faithI18n.ts is the slim shim — exports
m, FaithLang, FaithKey, plus the URL-slug helpers (langFromFaithSlug,
faithSlugFromLang, prayersSlug, rosarySlug, calendarSlug, apologetikSlug)
needed because faith routes do bidirectional slug ↔ locale mapping that
the other namespaces don't.

[faithLang]/+layout.svelte and +page.svelte fully migrated. The
isEnglish/isLatin derived flag dance collapses into a single typed
`lang`; ten inline ternaries per file (display labels and slug
selection) become t.key lookups or slug-helper calls. The "DE" badge
condition for non-German faith locales tightened from
`isEnglish || isLatin` to `lang !== 'de'`. Apologetik latin-fallback
hops through the helpers instead of inline matchers.

Apologetik pages get the shared-label cut: all four pages (contra,
contra detail, pro, pro detail) now use t.objections, t.evidences,
t.alex_pick, t.objection_label, t.answered_by, t.voices_answering,
t.arguments_title, t.positive_case from the dict. Page-specific
marketing copy (the per-page heading/lede/eyebrow object literals)
stays inline — those strings live in exactly one place each, the
structure is already readable, and pulling them into a shared dict
would be noise.

Also: ImageUpload.svelte was the one stray cospend t() caller the
earlier codemod missed (it lives at lib/components/, outside the
codemod's --root scope). Now uses t.key with `as CospendLang` cast.
2026-05-01 13:01:25 +02:00
Alexander 3347619816 refactor(i18n): split cospend + calendar per-locale, adopt t.key syntax
Cospend translations move to src/lib/i18n/cospend/{de,en}.ts with
satisfies-based key-set enforcement, mirroring the fitness layout
shipped earlier. cospendI18n.ts becomes the same kind of slim shim
exporting m, CospendLang, CospendKey while keeping every existing
helper (detectCospendLang, paymentCategoryName, splitDescription,
formatNextExecutionI18n, etc.) on the same surface.

Calendar gets the same treatment but with three locales (de/en/la)
and two namespaces — `ui` and the rite-1962-specific `ui1962`.
calendarI18n.ts now imports both as m / m1962, types them as
CalendarKey / Calendar1962Key, and routes t() / t1962() through
them. The 1962 fallback is per-namespace dir with file-prefixed
locale files (de_1962.ts etc.) so they can co-exist.

19 cospend route/component files and 3 calendar pages migrated to
the t.key / t1962.key syntax. Two notable hand fixes: UsersList.svelte
needed `as CospendLang` because the `lang` prop default uses an `as`
cast that breaks TS narrowing of m[lang]; and a sed pass converted
codemod-emitted t['camelCase'] to t.camelCase since the static-key
regex initially only matched snake_case.

The split + codemod scripts are now generic — split-i18n.ts takes
namespace, locales, optional marker and basename for multi-table
modules; codemod-i18n-t-to-m.ts takes module basename, fn name, and
m alias name (so t1962 / m1962 share the same machinery as t / m).
The fitness-specific one-shots are deleted, superseded.
2026-05-01 12:47:46 +02:00
Alexander ac05367ee4 refactor(fitness): adopt t.key / t[expr] syntax across fitness pages
22 files migrated from t('key', lang) function calls to direct lookups
on a derived dictionary alias: const t = $derived(m[lang]) once per
file, then t.start_period or t[card.labelKey] at the call sites.

Cleaner read at the point of use, one less argument threaded through,
and TypeScript narrows on every key access (so a typo in a literal
key now errors at the call site, not silently falls back to the key
string).

The codemod handles both ways `lang` can enter scope — derived from
the URL via detectFitnessLang, or destructured from $props() (single
or multi-line). One file aliased the i18n table to `messages` to
avoid collision with a local `const m = data.measurement`.

The deprecated `t(key, lang)` function still exists in fitnessI18n.ts
for any remaining out-of-tree call sites — can be deleted once
nothing imports it.
2026-05-01 12:25:49 +02:00
Alexander 609405da81 refactor(i18n): split fitness translations into per-locale files
The fitness UI translation table previously lived as one combined
object in fitnessI18n.ts where every entry held both languages. That
hides drift (an English string can silently disappear without TypeScript
noticing) and makes adding strings a multi-edit dance.

Split into src/lib/i18n/fitness/{de,en}.ts. de.ts is the source of
truth for the key set; en.ts uses `as const satisfies
Record<keyof typeof de, string>` so any missing English translation is
a build-time error. fitnessI18n.ts now re-exports both as a typed
table m and adds FitnessLang/FitnessKey types — the existing
t/fitnessSlugs/fitnessLabels API stays so call sites don't churn.

The strict typing immediately surfaced one real bug: t('initializing_gps')
was being called from the active workout page but the key never existed
in the dictionary, so it had been rendering the literal string
'initializing_gps' through the fallback. Added the missing key in both
locales.

Tightened BodyPartCard.labelKey and the body-parts Step JSDoc to
FitnessKey instead of plain string so card data drift catches drift at
the data site, not the call site. Two dynamic-key sites (partKeyMap
fallbacks for unmapped measurement keys) are cast pragmatically.

The 360-entry split was done by a one-shot extraction script
(scripts/split-fitness-i18n.ts) — kept for re-use against
cospendI18n.ts and calendarI18n.ts in follow-up commits.
2026-05-01 12:15:27 +02:00
Alexander c521a9ec68 feat(fitness/period): long-press calendar day to start a period
Holding any past or current calendar cell (outside an existing period
record and unless one is already ongoing) for 600ms now opens a
confirmation dialog and starts a period on that day. Same POST as the
button-driven start; just a faster gesture for back-dating today or
yesterday.

Implemented as an inline {@attach longPress(handler)} attachment that
cancels on >8px movement, suppresses iOS contextmenu, and respects
pointer cancel/leave. The held cell scales 1.18× with a growing red
ring and rounded pill border for visual feedback (reduced-motion
falls back to a static ring). Eligibility is gated client-side
(canStartOn): no read-only mode, no projection-only mode, no future
dates, and no overlap with the current period.
2026-04-30 19:19:20 +02:00
Alexander 936c59debc refactor(fitness): use:action → {@attach}, harden streamed-data error paths
Two custom Leaflet actions converted to attachments: renderMap is now
a factory returning an attachment, mountMap is the attachment itself.
Four call sites updated. use:enhance left alone — still the canonical
SvelteKit form-action API.

The stats page's three streamed Promise.resolve(...).then(...) chains
now log on rejection instead of silently swallowing errors. The muscle
heatmap {#await} block gained pending and catch branches with a
lang-aware error message.
2026-04-30 19:13:06 +02:00
Alexander d8abcbf74b refactor(hooks): move server bootstrap into ServerInit hook
Module-level top-level await for db/scheduler init and the cache
warmup IIFE move into the canonical export const init hook. Same
ordering and non-blocking semantics; makes the lifecycle explicit
and works on environments without top-level await.
2026-04-30 19:07:42 +02:00
120 changed files with 4233 additions and 2245 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.52.1",
"version": "1.57.3",
"private": true,
"type": "module",
"scripts": {
+126
View File
@@ -0,0 +1,126 @@
/**
* Migrate i18n call sites from t('key', lang) to t.key (or t[expr] for
* dynamic keys), where t = m[lang] derived once per file. Generic version
* — pass the i18n module path and the directories to scan.
*
* Usage:
* pnpm exec vite-node scripts/codemod-i18n-t-to-m.ts \
* --module=$lib/js/cospendI18n \
* --root=src/routes/'[cospendRoot=cospendRoot]' \
* --root=src/lib/components/cospend \
* [--dry]
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const args = process.argv.slice(2);
const DRY = args.includes('--dry');
const modArg = args.find((a) => a.startsWith('--module='));
if (!modArg) {
console.error('missing --module=<path>');
process.exit(1);
}
const modulePath = modArg.slice('--module='.length);
const roots = args
.filter((a) => a.startsWith('--root='))
.map((a) => a.slice('--root='.length));
if (roots.length === 0) {
console.error('missing --root=<dir> (at least one)');
process.exit(1);
}
const fnFlag = args.find((a) => a.startsWith('--fn='));
const FN = fnFlag ? fnFlag.slice('--fn='.length) : 't';
const mFlag = args.find((a) => a.startsWith('--m='));
const M_NAME = mFlag ? mFlag.slice('--m='.length) : 'm';
// Match imports from any path ending in the module basename — call sites
// reach calendarI18n via wildly different relative-path depths, so we
// don't pin the full path.
function esc(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const IMPORT_RE = new RegExp(
`import\\s*\\{([^}]+)\\}\\s*from\\s*(['"])([^'"]*${esc(modulePath)})\\2\\s*;?`
);
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) walk(p, out);
else if (extname(p) === '.svelte' || extname(p) === '.ts') out.push(p);
}
return out;
}
function migrate(src: string): { code: string; changed: boolean } {
const m0 = IMPORT_RE.exec(src);
if (!m0) return { code: src, changed: false };
const items = m0[1].split(',').map((s) => s.trim()).filter(Boolean);
if (!items.includes(FN)) return { code: src, changed: false };
const matchedPath = m0[3];
// 1. Rewrite import: drop FN, ensure M_NAME present. Preserve original path.
const fnIdx = items.indexOf(FN);
items.splice(fnIdx, 1);
if (!items.includes(M_NAME)) items.push(M_NAME);
let out = src.replace(IMPORT_RE, `import { ${items.join(', ')} } from '${matchedPath}';`);
// 2. Insert `const FN = $derived(M_NAME[lang]);` at the right spot.
const insertion = `const ${FN} = $derived(${M_NAME}[lang]);`;
let inserted = false;
const langDerivedRe =
/^([ \t]*)(const\s+lang\s*=\s*\$derived\((?:[^()]|\([^()]*\))+\)\s*;?)([ \t]*\n)/m;
if (langDerivedRe.test(out)) {
out = out.replace(langDerivedRe, (_, indent, decl, nl) => {
inserted = true;
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
});
}
if (!inserted) {
const propsRe =
/^([ \t]*)(let\s*\{[\s\S]*?\}\s*=\s*\$props(?:<[\s\S]*?>)?\(\)\s*;?)([ \t]*\n)/m;
out = out.replace(propsRe, (full, indent, decl, nl) => {
if (!/\blang\b/.test(decl)) return full;
inserted = true;
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
});
}
if (!inserted) {
console.warn(` WARN: could not auto-insert \`${insertion}\` — manual fix needed`);
}
// Build dynamic regex for FN(...) — escape `1962`-style suffixes.
const fnEsc = FN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 3. FN('static_key', lang) → FN.static_key (snake_case OR camelCase identifier)
out = out.replace(
new RegExp(`\\b${fnEsc}\\(\\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]\\s*,\\s*lang\\s*\\)`, 'g'),
`${FN}.$1`
);
// 4. FN(<expr>, lang) → FN[<expr>]
out = out.replace(
new RegExp(`\\b${fnEsc}\\(((?:[^()]|\\([^()]*\\))+?)\\s*,\\s*lang\\s*\\)`, 'g'),
(_match, expr) => `${FN}[${expr.trim()}]`
);
return { code: out, changed: out !== src };
}
let total = 0;
for (const root of roots) {
for (const f of walk(root)) {
const orig = readFileSync(f, 'utf8');
const { code, changed } = migrate(orig);
if (!changed) continue;
if (!DRY) writeFileSync(f, code);
total++;
console.log(` ${f}`);
}
}
console.log(`\n${DRY ? '[dry] ' : ''}${total} files migrated`);
+132
View File
@@ -0,0 +1,132 @@
/**
* Split a single-file i18n module (with an object literal whose values are
* `Record<locale, string>`) into per-locale files under
* src/lib/i18n/<namespace>/<locale>.ts.
*
* The first locale is the source of truth; others use `as const satisfies
* Record<keyof typeof <first>, string>` so missing translations fail
* type-checking.
*
* Run: pnpm exec vite-node scripts/split-i18n.ts <source> <namespace> <locales,csv> [--marker=<marker>] [--basename=<name>]
* e.g. ... cospendI18n.ts cospend de,en
* ... calendarI18n.ts calendar de,en,la --marker='export const ui = {' --basename=de
*
* Defaults: marker = `const translations: Translations = {`, basename = first locale.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
const [, , srcPath, namespace, localesCsv, ...flags] = process.argv;
if (!srcPath || !namespace || !localesCsv) {
console.error(
'usage: split-i18n.ts <source> <namespace> <locales,csv> [--marker=...] [--basename=...]'
);
process.exit(1);
}
const locales = localesCsv.split(',').map((s) => s.trim()).filter(Boolean);
const markerFlag = flags.find((f) => f.startsWith('--marker='));
const startMarker = markerFlag
? markerFlag.slice('--marker='.length)
: 'const translations: Translations = {';
const basenameFlag = flags.find((f) => f.startsWith('--basename='));
const fileBase = basenameFlag ? basenameFlag.slice('--basename='.length) : '';
const src = readFileSync(srcPath, 'utf8');
// Slice the translations object body
const startIdx = src.indexOf(startMarker);
if (startIdx === -1) throw new Error(`marker not found in ${srcPath}: ${startMarker}`);
// Object literal can close with `};` or `} as const;` — pick the earliest match.
const candA = src.indexOf('\n};', startIdx);
const candB = src.indexOf('\n} as const', startIdx);
const endIdx =
candA < 0 ? candB : candB < 0 ? candA : Math.min(candA, candB);
if (endIdx === -1) throw new Error('translations object end not found');
const body = src.slice(startIdx + startMarker.length, endIdx);
// Match each translation entry boundary: `key: { ...inner... },`. Each
// entry's body is then parsed independently for `loc: 'value'` pairs, so
// locale order in the source file doesn't matter.
const entryRe = /^\s*(\w+)\s*:\s*\{([\s\S]*?)\}\s*,?\s*$/gm;
// Match `loc: '...'` OR `loc: "..."` (double quotes are used when the string
// contains a literal apostrophe).
const localeRe = /(\w+)\s*:\s*(?:'([^']*)'|"((?:\\.|[^"\\])*)")/g;
function decodeJsString(raw: string, doubleQuoted: boolean): string {
if (doubleQuoted) {
// Already valid JSON (escapes preserved). Parse directly.
return JSON.parse('"' + raw + '"');
}
// Single-quoted: convert any \' → ' and escape literal " for JSON.
const jsonReady = '"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"';
return JSON.parse(jsonReady);
}
interface Entry {
key: string;
values: Record<string, string>;
}
const entries: Entry[] = [];
let m: RegExpExecArray | null;
while ((m = entryRe.exec(body)) !== null) {
const inner = m[2];
const values: Record<string, string> = {};
let lm: RegExpExecArray | null;
while ((lm = localeRe.exec(inner)) !== null) {
const single = lm[2];
const double = lm[3];
values[lm[1]] = single !== undefined
? decodeJsString(single, false)
: decodeJsString(double, true);
}
for (const loc of locales) {
if (!(loc in values)) {
throw new Error(`entry "${m[1]}" is missing locale "${loc}"`);
}
}
entries.push({ key: m[1], values });
}
console.log(`extracted ${entries.length} entries`);
const outDir = `src/lib/i18n/${namespace}`;
mkdirSync(outDir, { recursive: true });
const sourceLocale = locales[0];
// Optional file prefix lets us split multiple tables into the same dir
// (e.g. calendar `ui` → de.ts, calendar `ui1962` → de_1962.ts).
const path = (loc: string) => `${outDir}/${fileBase ? `${loc}_${fileBase}` : loc}.ts`;
// Write the source-of-truth locale (no satisfies clause).
{
const lines = [
'/** Generated by scripts/split-i18n.ts. */',
`/** ${sourceLocale.toUpperCase()} ${namespace}${fileBase ? ` (${fileBase})` : ''} UI strings — source of truth for the key set. */`,
'',
`export const ${sourceLocale} = {`
];
for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[sourceLocale])},`);
lines.push('} as const;', '');
writeFileSync(path(sourceLocale), lines.join('\n'));
}
// Write the other locales with `satisfies` constraint.
const sourceFile = fileBase ? `${sourceLocale}_${fileBase}` : sourceLocale;
for (let i = 1; i < locales.length; i++) {
const loc = locales[i];
const lines = [
'/** Generated by scripts/split-i18n.ts. */',
`import type { ${sourceLocale} } from './${sourceFile}';`,
'',
`export const ${loc} = {`
];
for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[loc])},`);
lines.push(
`} as const satisfies Record<keyof typeof ${sourceLocale}, string>;`,
''
);
writeFileSync(path(loc), lines.join('\n'));
}
console.log(`wrote ${locales.map(path).join(', ')}`);
+16 -17
View File
@@ -1,4 +1,4 @@
import type { Handle, HandleServerError } from "@sveltejs/kit"
import type { Handle, HandleServerError, ServerInit } from "@sveltejs/kit"
import { redirect } from "@sveltejs/kit"
import { sequence } from "@sveltejs/kit/hooks"
import * as auth from "./auth"
@@ -32,27 +32,26 @@ async function timing({ event, resolve }: Parameters<Handle>[0]) {
return response;
}
// Initialize database connection on server startup
console.log('🚀 Server starting - initializing database connection...');
await dbConnect().then(() => {
console.log('✅ Database connected successfully');
// Initialize the recurring payment scheduler after DB is ready
initializeScheduler();
console.log('✅ Recurring payment scheduler initialized');
}).catch((error) => {
console.error('❌ Failed to connect to database on startup:', error);
// Don't crash the server - API routes will attempt reconnection
});
export const init: ServerInit = async () => {
console.log('🚀 Server starting - initializing database connection...');
try {
await dbConnect();
console.log('✅ Database connected successfully');
initializeScheduler();
console.log('✅ Recurring payment scheduler initialized');
} catch (error) {
console.error('❌ Failed to connect to database on startup:', error);
// Don't crash the server - API routes will attempt reconnection
}
// Warm liturgical calendar cache in the background — non-blocking so the
// server starts accepting requests immediately; any request arriving before
// warmup completes falls back to lazy computation (still correct, just cold).
{
// Warm liturgical calendar cache in the background — non-blocking so the
// server starts accepting requests immediately; any request arriving before
// warmup completes falls back to lazy computation (still correct, just cold).
const t0 = performance.now();
warmLiturgicalCache()
.then(() => console.log(`✅ Liturgical calendar cache warmed in ${Math.round(performance.now() - t0)}ms`))
.catch((error) => console.error('⚠️ Liturgical calendar warmup failed:', error));
}
};
async function authorization({ event, resolve }: Parameters<Handle>[0]) {
const session = await event.locals.timing.measure('auth', () => event.locals.auth());
+6 -3
View File
@@ -2,7 +2,10 @@
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Calendar from '@lucide/svelte/icons/calendar';
import { m } from '$lib/js/commonI18n';
/** @typedef {import('$lib/js/commonI18n').CommonLang} CommonLang */
let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props();
const t = $derived(m[/** @type {CommonLang} */ (lang)]);
let open = $state(false);
/** @type {HTMLDivElement | null} */
@@ -35,8 +38,8 @@
const todayStr = new Date().toISOString().slice(0, 10);
const displayDate = $derived.by(() => {
if (!value) return lang === 'en' ? 'Select date' : 'Datum wählen';
if (value === todayStr) return lang === 'en' ? 'Today' : 'Heute';
if (!value) return t.select_date;
if (value === todayStr) return t.today;
const d = new Date(value + 'T12:00:00');
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', month: 'short', day: 'numeric' });
});
@@ -182,7 +185,7 @@
{#if value !== todayStr}
<button type="button" class="dp-today-btn" onclick={goToday}>
{lang === 'en' ? 'Today' : 'Heute'}
{t.today}
</button>
{/if}
</div>
+3 -1
View File
@@ -5,6 +5,7 @@
import SearchX from '@lucide/svelte/icons/search-x';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import { m } from '$lib/js/commonI18n';
interface BibleQuote {
text: string;
reference: string;
@@ -43,6 +44,7 @@
}
let Icon = $derived(icon ?? defaultIcon(status));
const t = $derived(m[isEnglish ? 'en' : 'de']);
let openQuote = $derived(isEnglish ? '\u201C' : '\u201E');
let closeQuote = $derived(isEnglish ? '\u201D' : '\u201C');
</script>
@@ -52,7 +54,7 @@
<header class="eyebrow">
<Icon size={14} strokeWidth={1.5} aria-hidden="true" />
<span class="eyebrow-label">
{isEnglish ? 'Error' : 'Fehler'}
{t.error_label}
</span>
</header>
+11 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { t } from '$lib/js/cospendI18n';
import { m, type CospendLang } from '$lib/js/cospendI18n';
let {
imagePreview = $bindable(''),
@@ -24,20 +24,21 @@
onimageRemoved?: () => void,
oncurrentImageRemoved?: () => void
}>();
const t = $derived(m[lang as CospendLang]);
const displayTitle = $derived(title ?? t('receipt_image', lang));
const displayTitle = $derived(title ?? t.receipt_image);
function handleImageChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
onerror?.(t('file_too_large', lang));
onerror?.(t.file_too_large);
return;
}
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
onerror?.(t('invalid_image', lang));
onerror?.(t.invalid_image);
return;
}
@@ -70,10 +71,10 @@
{#if currentImage}
<div class="current-image">
<img src={currentImage} alt={t('receipt', lang)} class="receipt-preview" />
<img src={currentImage} alt={t.receipt} class="receipt-preview" />
<div class="image-actions">
<button type="button" class="btn-remove" onclick={removeCurrentImage}>
{t('remove_image', lang)}
{t.remove_image}
</button>
</div>
</div>
@@ -81,9 +82,9 @@
{#if imagePreview}
<div class="image-preview">
<img src={imagePreview} alt={t('receipt', lang)} />
<img src={imagePreview} alt={t.receipt} />
<button type="button" class="remove-image" onclick={removeImage}>
{t('remove_image', lang)}
{t.remove_image}
</button>
</div>
{:else}
@@ -95,7 +96,7 @@
<line x1="16" y1="5" x2="22" y2="5"/>
<line x1="19" y1="2" x2="19" y2="8"/>
</svg>
<p>{currentImage ? t('replace_image', lang) : t('upload_receipt', lang)}</p>
<p>{currentImage ? t.replace_image : t.upload_receipt}</p>
<small>JPEG, PNG, WebP (max 5MB)</small>
</div>
</label>
@@ -111,7 +112,7 @@
{/if}
{#if uploading}
<div class="upload-status">{t('uploading_image', lang)}</div>
<div class="upload-status">{t.uploading_image}</div>
{/if}
</div>
+9 -7
View File
@@ -1,20 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pwaStore } from '$lib/stores/pwa.svelte';
import { m, type CommonLang } from '$lib/js/commonI18n';
let { lang = 'de' }: { lang?: string } = $props();
let showTooltip = $state(false);
let mounted = $state(false);
const t = $derived(m[lang as CommonLang]);
const labels = $derived({
syncForOffline: lang === 'en' ? 'Save for offline' : 'Offline speichern',
syncing: lang === 'en' ? 'Syncing...' : 'Synchronisiere...',
offlineReady: lang === 'en' ? 'Offline ready' : 'Offline bereit',
lastSync: lang === 'en' ? 'Last sync' : 'Letzte Sync',
recipes: lang === 'en' ? 'recipes' : 'Rezepte',
syncNow: lang === 'en' ? 'Sync now' : 'Jetzt synchronisieren',
clearData: lang === 'en' ? 'Clear offline data' : 'Offline-Daten löschen'
syncForOffline: t.sync_for_offline,
syncing: t.syncing,
offlineReady: t.offline_ready,
lastSync: t.last_sync,
recipes: t.recipes_word,
syncNow: t.sync_now,
clearData: t.clear_offline_data
});
onMount(async () => {
+4 -2
View File
@@ -4,8 +4,10 @@
import { page } from '$app/state';
import { browser } from '$app/environment';
import LogIn from '@lucide/svelte/icons/log-in';
import { m, type CommonLang } from '$lib/js/commonI18n';
let { user, recipeLang = 'rezepte', lang = 'de' } = $props();
const t = $derived(m[lang as CommonLang]);
function toggle_options(){
const el = document.querySelector("#options-wrap") as HTMLElement | null;
@@ -167,8 +169,8 @@
<a
class="entry login-link"
href={`${resolve('/login')}?callbackUrl=${encodeURIComponent(page.url.pathname + (browser ? page.url.search : ''))}`}
aria-label={lang === 'de' ? 'Anmelden' : 'Login'}
title={lang === 'de' ? 'Anmelden' : 'Login'}
aria-label={t.login}
title={t.login}
>
<LogIn size={18} />
</a>
+11 -10
View File
@@ -3,9 +3,10 @@
import { page } from '$app/state';
import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency } from '$lib/utils/formatters';
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
import { detectCospendLang, locale, m } from '$lib/js/cospendI18n';
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang));
/**
@@ -66,19 +67,19 @@
{#if !shouldHide}
<div class="debt-breakdown">
<h2>{t('debt_overview', lang)}</h2>
<h2>{t.debt_overview}</h2>
{#if loading}
<div class="loading">{t('loading_debt_breakdown', lang)}</div>
<div class="loading">{t.loading_debt_breakdown}</div>
{:else if error}
<div class="error">{t('error_prefix', lang)}: {error}</div>
<div class="error">{t.error_prefix}: {error}</div>
{:else}
<div class="debt-sections">
{#if debtData.whoOwesMe.length > 0}
<div class="debt-section owed-to-me">
<h3>{t('who_owes_you', lang)}</h3>
<h3>{t.who_owes_you}</h3>
<div class="total-amount positive">
{t('total', lang)}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
{t.total}: {formatCurrency(debtData.totalOwedToMe, 'CHF', loc)}
</div>
<div class="debt-list">
@@ -92,7 +93,7 @@
</div>
</div>
<div class="transaction-count">
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
{debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
</div>
</div>
{/each}
@@ -102,9 +103,9 @@
{#if debtData.whoIOwe.length > 0}
<div class="debt-section owe-to-others">
<h3>{t('you_owe_section', lang)}</h3>
<h3>{t.you_owe_section}</h3>
<div class="total-amount negative">
{t('total', lang)}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
{t.total}: {formatCurrency(debtData.totalIOwe, 'CHF', loc)}
</div>
<div class="debt-list">
@@ -118,7 +119,7 @@
</div>
</div>
<div class="transaction-count">
{debt.transactions.length} {debt.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
{debt.transactions.length} {debt.transactions.length !== 1 ? t.transactions : t.transaction}
</div>
</div>
{/each}
@@ -3,9 +3,10 @@
import { page } from '$app/state';
import ProfilePicture from './ProfilePicture.svelte';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
import { detectCospendLang, locale, t } from '$lib/js/cospendI18n';
import { detectCospendLang, locale, m } from '$lib/js/cospendI18n';
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang));
let { initialBalance = null, initialDebtData = null } = $props<{ initialBalance?: any, initialDebtData?: any }>();
@@ -122,26 +123,26 @@
{#if loading}
<div class="loading-content">
<h3>{t('your_balance', lang)}</h3>
<div class="loading">{t('loading', lang)}</div>
<h3>{t.your_balance}</h3>
<div class="loading">{t.loading}</div>
</div>
{:else if error}
<h3>{t('your_balance', lang)}</h3>
<div class="error">{t('error_prefix', lang)}: {error}</div>
<h3>{t.your_balance}</h3>
<div class="error">{t.error_prefix}: {error}</div>
{:else if shouldShowIntegratedView}
<!-- Enhanced view with single user debt -->
<h3>{t('your_balance', lang)}</h3>
<h3>{t.your_balance}</h3>
<div class="enhanced-balance">
<div class="main-amount">
{#if balance.netBalance < 0}
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
<small>{t('you_are_owed', lang)}</small>
<small>{t.you_are_owed}</small>
{:else if balance.netBalance > 0}
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
<small>{t('you_owe_balance', lang)}</small>
<small>{t.you_owe_balance}</small>
{:else}
<span class="even">CHF 0.00</span>
<small>{t('all_even', lang)}</small>
<small>{t.all_even}</small>
{/if}
</div>
@@ -154,9 +155,9 @@
<span class="username">{singleDebtUser.user.username}</span>
<span class="debt-description">
{#if singleDebtUser.type === 'owesMe'}
{t('owes_you_balance', lang)} {formatCurrency(singleDebtUser.amount)}
{t.owes_you_balance} {formatCurrency(singleDebtUser.amount)}
{:else}
{t('you_owe_user', lang)} {formatCurrency(singleDebtUser.amount)}
{t.you_owe_user} {formatCurrency(singleDebtUser.amount)}
{/if}
</span>
</div>
@@ -166,24 +167,24 @@
</div>
<div class="transaction-count">
{#if singleDebtUser && singleDebtUser.user && singleDebtUser.user.transactions}
{singleDebtUser.user.transactions.length} {singleDebtUser.user.transactions.length !== 1 ? t('transactions', lang) : t('transaction', lang)}
{singleDebtUser.user.transactions.length} {singleDebtUser.user.transactions.length !== 1 ? t.transactions : t.transaction}
{/if}
</div>
</div>
</div>
{:else}
<!-- Standard balance view -->
<h3>{t('your_balance', lang)}</h3>
<h3>{t.your_balance}</h3>
<div class="amount">
{#if balance.netBalance < 0}
<span class="positive">+{formatCurrency(balance.netBalance)}</span>
<small>{t('you_are_owed', lang)}</small>
<small>{t.you_are_owed}</small>
{:else if balance.netBalance > 0}
<span class="negative">-{formatCurrency(balance.netBalance)}</span>
<small>{t('you_owe_balance', lang)}</small>
<small>{t.you_owe_balance}</small>
{:else}
<span class="even">CHF 0.00</span>
<small>{t('all_even', lang)}</small>
<small>{t.all_even}</small>
{/if}
</div>
{/if}
+19 -18
View File
@@ -7,7 +7,7 @@
import EditButton from '$lib/components/EditButton.svelte';
import { getCategoryEmoji } from '$lib/utils/categories';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, splitDescription, paymentCategoryName, m } from '$lib/js/cospendI18n';
import { confirm } from '$lib/js/confirmDialog.svelte';
let { paymentId, onclose, onpaymentDeleted } = $props();
@@ -16,6 +16,7 @@
let session = $derived(page.data?.session);
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -112,7 +113,7 @@
let deleting = $state(false);
async function deletePayment() {
if (!await confirm(t('delete_payment_confirm', lang))) {
if (!await confirm(t.delete_payment_confirm)) {
return;
}
@@ -140,7 +141,7 @@
<div class="panel-content" bind:this={modal}>
<div class="panel-header">
<h2>{t('payment_details', lang)}</h2>
<h2>{t.payment_details}</h2>
<button class="close-button" onclick={closeModal} aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
@@ -151,9 +152,9 @@
<div class="panel-body">
{#if loading}
<div class="loading">{t('loading_payments', lang)}</div>
<div class="loading">{t.loading_payments}</div>
{:else if error}
<div class="error">{t('error_prefix', lang)}: {error}</div>
<div class="error">{t.error_prefix}: {error}</div>
{:else if payment}
<div class="payment-details">
<div class="payment-header">
@@ -168,7 +169,7 @@
</div>
{#if payment.image}
<div class="receipt-image">
<img src={payment.image} alt={t('receipt', lang)} />
<img src={payment.image} alt={t.receipt} />
</div>
{/if}
</div>
@@ -176,30 +177,30 @@
<div class="payment-info">
<div class="info-grid">
<div class="info-item">
<span class="label">{t('date', lang)}</span>
<span class="label">{t.date}</span>
<span class="value">{formatDate(payment.date)}</span>
</div>
<div class="info-item">
<span class="label">{t('paid_by_label', lang)}</span>
<span class="label">{t.paid_by_label}</span>
<span class="value">{payment.paidBy}</span>
</div>
<div class="info-item">
<span class="label">{t('created_by', lang)}</span>
<span class="label">{t.created_by}</span>
<span class="value">{payment.createdBy}</span>
</div>
<div class="info-item">
<span class="label">{t('category_label', lang)}</span>
<span class="label">{t.category_label}</span>
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
</div>
<div class="info-item">
<span class="label">{t('split_method_label', lang)}</span>
<span class="label">{t.split_method_label}</span>
<span class="value">{getSplitDescription(payment)}</span>
</div>
</div>
{#if payment.description}
<div class="description">
<h3>{t('description', lang)}</h3>
<h3>{t.description}</h3>
<p>{payment.description}</p>
</div>
{/if}
@@ -207,7 +208,7 @@
{#if payment.splits && payment.splits.length > 0}
<div class="splits-section">
<h3>{t('split_details', lang)}</h3>
<h3>{t.split_details}</h3>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item" class:current-user={split.username === session?.user?.nickname}>
@@ -216,17 +217,17 @@
<div class="user-info">
<span class="username">{split.username}</span>
{#if split.username === session?.user?.nickname}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
</div>
</div>
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount)}
{t.owes} {formatCurrency(split.amount)}
{:else if split.amount < 0}
{t('owed', lang)} {formatCurrency(split.amount)}
{t.owed} {formatCurrency(split.amount)}
{:else}
{t('even', lang)}
{t.even}
{/if}
</div>
</div>
@@ -236,7 +237,7 @@
{/if}
<div class="panel-actions">
<button class="btn-secondary" onclick={closeModal}>{t('close', lang)}</button>
<button class="btn-secondary" onclick={closeModal}>{t.close}</button>
</div>
</div>
{/if}
@@ -1,9 +1,10 @@
<script>
import ProfilePicture from './ProfilePicture.svelte';
import { page } from '$app/state';
import { detectCospendLang, t } from '$lib/js/cospendI18n';
import { detectCospendLang, m } from '$lib/js/cospendI18n';
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
let {
splitMethod = $bindable('equal'),
@@ -22,20 +23,20 @@
// Reactive text for "Paid in Full" option
let paidInFullText = $derived((() => {
if (!paidBy) {
return t('paid_in_full', lang);
return t.paid_in_full;
}
// Special handling for 2-user predefined setup
if (predefinedMode && users.length === 2) {
const otherUser = users.find((/** @type {string} */ user) => user !== paidBy);
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang);
return otherUser ? `${t.paid_in_full_for} ${otherUser}` : t.paid_in_full;
}
// General case
if (paidBy === currentUser) {
return t('paid_in_full_by_you', lang);
return t.paid_in_full_by_you;
} else {
return `${t('paid_in_full_by', lang)} ${paidBy}`;
return `${t.paid_in_full_by} ${paidBy}`;
}
})());
@@ -132,21 +133,21 @@
</script>
<div class="form-section">
<h2>{t('split_method', lang)}</h2>
<h2>{t.split_method}</h2>
<div class="form-group">
<label for="splitMethod">{t('how_split', lang)}</label>
<label for="splitMethod">{t.how_split}</label>
<select id="splitMethod" name="splitMethod" bind:value={splitMethod} required>
<option value="equal">{predefinedMode && users.length === 2 ? t('split_5050', lang) : t('equal_split', lang)}</option>
<option value="personal_equal">{t('personal_equal_split', lang)}</option>
<option value="equal">{predefinedMode && users.length === 2 ? t.split_5050 : t.equal_split}</option>
<option value="personal_equal">{t.personal_equal_split}</option>
<option value="full">{paidInFullText}</option>
<option value="proportional">{t('custom_proportions', lang)}</option>
<option value="proportional">{t.custom_proportions}</option>
</select>
</div>
{#if splitMethod === 'proportional'}
<div class="proportional-splits">
<h3>{t('custom_split_amounts', lang)}</h3>
<h3>{t.custom_split_amounts}</h3>
{#each users as user}
<div class="split-input">
<label for="split_{user}">{user}</label>
@@ -165,8 +166,8 @@
{#if splitMethod === 'personal_equal'}
<div class="personal-splits">
<h3>{t('personal_amounts', lang)}</h3>
<p class="description">{t('personal_amounts_desc', lang)}</p>
<h3>{t.personal_amounts}</h3>
<p class="description">{t.personal_amounts_desc}</p>
{#each users as user}
<div class="split-input">
<label for="personal_{user}">{user}</label>
@@ -184,10 +185,10 @@
{#if amount}
{@const personalTotal = Object.values(personalAmounts).reduce((/** @type {number} */ sum, /** @type {number} */ val) => sum + (Number(val) || 0), 0)}
<div class="remainder-info" class:error={personalTotalError}>
<span>{t('total_personal', lang)}: {currency} {personalTotal.toFixed(2)}</span>
<span>{t('remainder_to_split', lang)}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span>
<span>{t.total_personal}: {currency} {personalTotal.toFixed(2)}</span>
<span>{t.remainder_to_split}: {currency} {Math.max(0, Number(amount) - personalTotal).toFixed(2)}</span>
{#if personalTotalError}
<div class="error-message">{t('personal_exceeds_total', lang)}</div>
<div class="error-message">{t.personal_exceeds_total}</div>
{/if}
</div>
{/if}
@@ -196,7 +197,7 @@
{#if Object.keys(splitAmounts).length > 0}
<div class="split-preview">
<h3>{t('split_preview', lang)}</h3>
<h3>{t.split_preview}</h3>
{#each users as user}
<div class="split-item">
<div class="split-user">
@@ -205,11 +206,11 @@
</div>
<span class="amount" class:positive={splitAmounts[user] < 0} class:negative={splitAmounts[user] > 0}>
{#if splitAmounts[user] > 0}
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)}
{t.owes} {currency} {splitAmounts[user].toFixed(2)}
{:else if splitAmounts[user] < 0}
{t('is_owed', lang)} {currency} {Math.abs(splitAmounts[user]).toFixed(2)}
{t.is_owed} {currency} {Math.abs(splitAmounts[user]).toFixed(2)}
{:else}
{t('owes', lang)} {currency} {splitAmounts[user].toFixed(2)}
{t.owes} {currency} {splitAmounts[user].toFixed(2)}
{/if}
</span>
</div>
+9 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import ProfilePicture from './ProfilePicture.svelte';
import { t } from '$lib/js/cospendI18n';
import { m, type CospendLang } from '$lib/js/cospendI18n';
let {
users = $bindable([]),
@@ -17,6 +17,7 @@
newUser?: string,
lang?: 'en' | 'de'
}>();
const t = $derived(m[lang as CospendLang]);
function addUser() {
if (predefinedMode) return;
@@ -38,18 +39,18 @@
</script>
<div class="form-section">
<h2>{t('split_between_users', lang)}</h2>
<h2>{t.split_between_users}</h2>
{#if predefinedMode}
<div class="predefined-users">
<p class="predefined-note">{t('predefined_note', lang)}</p>
<p class="predefined-note">{t.predefined_note}</p>
<div class="users-list">
{#each users as user}
<div class="user-item with-profile">
<ProfilePicture username={user} size={32} />
<span class="username">{user}</span>
{#if user === currentUser}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
</div>
{/each}
@@ -62,11 +63,11 @@
<ProfilePicture username={user} size={32} />
<span class="username">{user}</span>
{#if user === currentUser}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
{#if canRemoveUsers && user !== currentUser}
<button type="button" class="remove-user" onclick={() => removeUser(user)}>
{t('remove', lang)}
{t.remove}
</button>
{/if}
</div>
@@ -77,10 +78,10 @@
<input
type="text"
bind:value={newUser}
placeholder={t('add_user_placeholder', lang)}
placeholder={t.add_user_placeholder}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addUser())}
/>
<button type="button" onclick={addUser}>{t('add_user', lang)}</button>
<button type="button" onclick={addUser}>{t.add_user}</button>
</div>
{/if}
</div>
@@ -5,6 +5,7 @@ import StreakAura from '$lib/components/faith/StreakAura.svelte';
import Coffee from '@lucide/svelte/icons/coffee';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import { m, type FaithLang } from '$lib/js/faithI18n';
import { tick, onMount } from 'svelte';
let burst = $state(false);
@@ -13,14 +14,13 @@ let selectedSlot = $state<TimeSlot>('morning');
interface Props {
streakData?: { streak: number; lastComplete: string | null; todayPrayed: number; todayDate: string | null } | null;
lang?: 'de' | 'en' | 'la';
lang?: FaithLang;
isLoggedIn?: boolean;
}
let { streakData = null, lang = 'de', isLoggedIn = false }: Props = $props();
const isEnglish = $derived(lang === 'en');
const isLatin = $derived(lang === 'la');
const t = $derived(m[lang]);
// Display values: store when available, SSR fallback
const displayStreak = $derived(store?.streak ?? streakData?.streak ?? 0);
@@ -52,20 +52,12 @@ const slots: { key: TimeSlot; icon: typeof Coffee; color: string }[] = [
{ key: 'evening', icon: Moon, color: 'var(--nord15)' }
];
const labels = $derived({
days: isLatin ? 'Dies' : isEnglish ? (displayStreak === 1 && !showFraction ? 'Day' : 'Days') : (displayStreak === 1 && !showFraction ? 'Tag' : 'Tage'),
pray: isLatin ? 'Oravi' : isEnglish ? 'Prayed' : 'Gebetet',
done: isLatin ? 'Hodie completa' : isEnglish ? 'Done today' : 'Heute fertig',
morning: isLatin ? 'Mane' : isEnglish ? 'Morning' : 'Morgens',
noon: isLatin ? 'Meridie' : isEnglish ? 'Noon' : 'Mittags',
evening: isLatin ? 'Vespere' : isEnglish ? 'Evening' : 'Abends',
ariaLabel: isLatin ? 'Orationem notatam fac' : isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
});
const dayLabel = $derived(displayStreak === 1 && !showFraction ? t.day_singular : t.day_plural);
const slotLabels: Record<TimeSlot, string> = $derived({
morning: labels.morning,
noon: labels.noon,
evening: labels.evening
morning: t.morning,
noon: t.noon,
evening: t.evening
});
function isSlotPrayed(slot: TimeSlot): boolean {
@@ -105,7 +97,7 @@ async function pray() {
{displayStreak}{#if showFraction}<span class="fraction"><span class="num">{partialCount}</span><span class="slash">/</span><span class="den">3</span></span>{/if}
</span>
</StreakAura>
<span class="streak-label">{labels.days}</span>
<span class="streak-label">{dayLabel}</span>
</div>
<div class="prayer-controls">
@@ -132,12 +124,12 @@ async function pray() {
class="pray-button"
type="submit"
disabled={todayComplete || selectedSlotPrayed}
aria-label={labels.ariaLabel}
aria-label={t.mark_prayer}
>
{#if todayComplete}
{labels.done}
{t.done_today}
{:else}
{labels.pray}
{t.prayed}
{/if}
</button>
</form>
+7 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { VerseData } from '$lib/data/mysteryDescriptions';
import { m, type FaithLang } from '$lib/js/faithI18n';
let {
reference = '',
@@ -11,11 +12,11 @@
reference?: string,
title?: string,
verseData?: VerseData | null,
lang?: string,
lang?: FaithLang,
onClose: () => void
} = $props();
const isEnglish = $derived(lang === 'en');
const t = $derived(m[lang]);
// svelte-ignore state_referenced_locally
let book: string = $state(verseData?.book || '');
@@ -25,7 +26,7 @@
let verses: Array<{ verse: number; text: string }> = $state(verseData?.verses || []);
let loading = $state(false);
// svelte-ignore state_referenced_locally
let error = $state(verseData ? '' : (lang === 'en' ? 'No verse data available' : 'Keine Versdaten verfügbar'));
let error = $state(verseData ? '' : m[lang].no_verse_data);
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
@@ -57,7 +58,7 @@
{/if}
<p class="modal-reference">{reference}</p>
</div>
<button class="close-button" onclick={onClose} aria-label={isEnglish ? 'Close' : 'Schliessen'}>
<button class="close-button" onclick={onClose} aria-label={t.close}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
@@ -67,7 +68,7 @@
<div class="modal-body">
{#if loading}
<p class="loading">{isEnglish ? 'Loading...' : 'Lädt...'}</p>
<p class="loading">{t.loading}</p>
{:else if error}
<p class="error">{error}</p>
{:else if verses.length > 0}
@@ -80,7 +81,7 @@
{/each}
</div>
{:else}
<p class="error">{isEnglish ? 'No verses found' : 'Keine Verse gefunden'}</p>
<p class="error">{t.no_verses_found}</p>
{/if}
</div>
</div>
+8 -14
View File
@@ -2,6 +2,7 @@
import { browser } from '$app/environment';
import { getRosaryStreak } from '$lib/stores/rosaryStreak.svelte';
import StreakAura from '$lib/components/faith/StreakAura.svelte';
import { m, type FaithLang } from '$lib/js/faithI18n';
import { tick, onMount } from 'svelte';
let burst = $state(false);
@@ -9,26 +10,19 @@ let streak = $state<ReturnType<typeof getRosaryStreak> | null>(null);
interface Props {
streakData?: { length: number; lastPrayed: string | null } | null;
lang?: 'de' | 'en' | 'la';
lang?: FaithLang;
isLoggedIn?: boolean;
}
let { streakData = null, lang = 'de', isLoggedIn = false }: Props = $props();
const isEnglish = $derived(lang === 'en');
const t = $derived(m[lang]);
// Derive display values: use store when available, fall back to server data for SSR
let displayLength = $derived(streak?.length ?? streakData?.length ?? 0);
let prayedToday = $derived(streak?.prayedToday ?? (streakData?.lastPrayed === new Date().toISOString().split('T')[0]));
// Labels need to come after displayLength since they depend on it
const isLatin = $derived(lang === 'la');
const labels = $derived({
days: isLatin ? (displayLength === 1 ? 'Dies' : 'Dies') : isEnglish ? (displayLength === 1 ? 'Day' : 'Days') : (displayLength === 1 ? 'Tag' : 'Tage'),
prayed: isLatin ? 'Oravi' : isEnglish ? 'Prayed' : 'Gebetet',
prayedToday: isLatin ? 'Hodie oravi' : isEnglish ? 'Prayed today' : 'Heute gebetet',
ariaLabel: isLatin ? 'Orationem notatam fac' : isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
});
const dayLabel = $derived(displayLength === 1 ? t.day_singular : t.day_plural);
// Initialize store on mount (client-side only)
// Init with server data BEFORE assigning to streak, so displayLength
@@ -50,7 +44,7 @@ async function pray() {
<div class="streak-container" class:no-js-hidden={!isLoggedIn}>
<div class="streak-display">
<StreakAura value={displayLength} {burst} />
<span class="streak-label">{labels.days}</span>
<span class="streak-label">{dayLabel}</span>
</div>
<form method="POST" action="?/pray" onsubmit={(e) => { e.preventDefault(); pray(); }
}>
@@ -58,12 +52,12 @@ async function pray() {
class="streak-button"
type="submit"
disabled={prayedToday}
aria-label={labels.ariaLabel}
aria-label={t.mark_prayer}
>
{#if prayedToday}
{labels.prayedToday}
{t.prayed_today}
{:else}
{labels.prayed}
{t.prayed}
{/if}
</button>
</form>
@@ -10,9 +10,10 @@
import Shapes from '@lucide/svelte/icons/shapes';
import Weight from '@lucide/svelte/icons/weight';
import { page } from '$app/state';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang(page.url.pathname));
const t = $derived(m[lang]);
const isEn = $derived(lang === 'en');
/**
@@ -81,7 +82,7 @@
<div class="picker-backdrop" onclick={onClose}></div>
<div class="picker-panel">
<div class="picker-header">
<h2>{t('picker_title', lang)}</h2>
<h2>{t.picker_title}</h2>
<button class="close-btn" onclick={onClose} aria-label="Close">
<X size={20} />
</button>
@@ -91,7 +92,7 @@
<Search size={16} />
<input
type="text"
placeholder={t('search_exercises', lang)}
placeholder={t.search_exercises}
bind:value={query}
/>
</div>
@@ -154,7 +155,7 @@
</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">{t('no_exercises_found', lang)}</li>
<li class="no-results">{t.no_exercises_found}</li>
{/if}
</ul>
</div>
+7 -6
View File
@@ -7,7 +7,7 @@
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 { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
import MacroBreakdown from './MacroBreakdown.svelte';
/**
@@ -51,9 +51,10 @@
} = $props();
const lang = $derived(detectFitnessLang(page.url.pathname));
const t = $derived(m[lang]);
const s = $derived(fitnessSlugs(lang));
const isEn = $derived(lang === 'en');
const btnLabel = $derived(confirmLabel ?? t('log_food', lang));
const btnLabel = $derived(confirmLabel ?? t.log_food);
// --- Search state ---
let query = $state('');
@@ -434,7 +435,7 @@
<input
type="text"
class="fs-search-input"
placeholder={t('search_food', lang)}
placeholder={t.search_food}
bind:value={query}
oninput={doSearch}
autofocus={autofocus}
@@ -455,7 +456,7 @@
<p class="fs-scan-error">{scanError}</p>
{/if}
{#if loading}
<p class="fs-status">{t('loading', lang)}</p>
<p class="fs-status">{t.loading}</p>
{/if}
{#if displayResults.length > 0}
<div class="fs-results">
@@ -487,7 +488,7 @@
</div>
{/if}
{#if oncancel}
<button class="fs-btn-cancel" onclick={oncancel}>{t('cancel', lang)}</button>
<button class="fs-btn-cancel" onclick={oncancel}>{t.cancel}</button>
{/if}
{:else}
<!-- Selected food — detail & amount -->
@@ -546,7 +547,7 @@
{/if}
<div class="fs-actions">
<button class="fs-btn-cancel" onclick={() => { selected = null; }}>{t('cancel', lang)}</button>
<button class="fs-btn-cancel" onclick={() => { selected = null; }}>{t.cancel}</button>
<button class="fs-btn-confirm" onclick={confirm}>{btnLabel}</button>
</div>
</div>
@@ -3,7 +3,7 @@
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';
import { m } from '$lib/js/fitnessI18n';
/** @type {{ value?: 'breakfast' | 'lunch' | 'dinner' | 'snack', lang?: 'en' | 'de', onchange?: (meal: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void }} */
let {
@@ -11,6 +11,7 @@
lang = 'de',
onchange = () => {},
} = $props();
const t = $derived(m[lang]);
/** @type {Array<'breakfast' | 'lunch' | 'dinner' | 'snack'>} */
const mealTypes = ['breakfast', 'lunch', 'dinner', 'snack'];
@@ -32,7 +33,7 @@
class:active={value === meal}
style="--mc: {meta.color}"
onclick={() => { onchange(meal); }}
title={t(meal, lang)}
title={t[meal]}
>
<MealIcon size={14} />
</button>
+204 -50
View File
@@ -1,5 +1,5 @@
<script>
import { t } from '$lib/js/fitnessI18n';
import { m } from '$lib/js/fitnessI18n';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
@@ -17,6 +17,7 @@
* @type {{ periods: any[], lang: 'en' | 'de', sharedWith?: string[], readOnly?: boolean, ownerName?: string, mode?: 'entry' | 'projection' | 'full' }}
*/
let { periods: initialPeriods = [], lang = 'en', sharedWith: initialSharedWith = [], readOnly = false, ownerName = '', mode = 'full' } = $props();
const t = $derived(m[lang]);
const showEntry = $derived(mode !== 'projection');
const showProjection = $derived(mode !== 'entry');
@@ -116,7 +117,7 @@
const startDay = start.toLocaleDateString(locale, { weekday: 'long' });
const endDay = end.toLocaleDateString(locale, { weekday: 'long' });
const diffDays = Math.round((midnight(start) - todayMidnight) / 86400000);
const toWord = t('to', lang);
const toWord = t.to;
if (diffDays >= 0 && diffDays < 7) {
return lang === 'de'
@@ -450,6 +451,125 @@
finally { loading = false; }
}
/** @param {string} dateStr — YYYY-MM-DD from a calendar cell */
async function promptStartPeriodOn(dateStr) {
const d = new Date(parseLocal(dateStr));
const ok = await confirm(
lang === 'de'
? `Periode am ${formatDate(d)} starten?`
: `Start period on ${formatDate(d)}?`,
{
title: lang === 'de' ? 'Periode starten' : 'Start period',
confirmText: lang === 'de' ? 'Starten' : 'Start',
cancelText: lang === 'de' ? 'Abbrechen' : 'Cancel',
destructive: false
}
);
if (!ok) return;
loading = true;
try {
const res = await fetch('/api/fitness/period', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ startDate: d.toISOString() })
});
if (res.ok) {
const { entry } = await res.json();
periods = [entry, ...periods];
} else {
const err = await res.json().catch(() => null);
toast.error(err?.error ?? 'Failed to start period');
}
} catch { toast.error('Failed to start period'); }
finally { loading = false; }
}
/**
* Long-press attachment. Fires `handler` after THRESHOLD ms of unmoving
* pointer contact. Cancels on movement > MOVE_TOL, pointer leave/cancel,
* or release before threshold. Suppresses the browser context menu when
* the gesture fires (iOS otherwise pops a callout on touch hold).
*
* @param {() => void} handler
* @returns {import('svelte/attachments').Attachment<HTMLElement>}
*/
function longPress(handler) {
const THRESHOLD = 600;
const MOVE_TOL = 8;
return (node) => {
/** @type {number | null} */
let timer = null;
let startX = 0;
let startY = 0;
let firing = false;
function clear() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
node.classList.remove('long-pressing');
}
/** @param {PointerEvent} e */
function onPointerDown(e) {
if (e.button !== undefined && e.button !== 0) return;
startX = e.clientX;
startY = e.clientY;
firing = false;
node.classList.add('long-pressing');
timer = window.setTimeout(() => {
firing = true;
node.classList.remove('long-pressing');
timer = null;
handler();
}, THRESHOLD);
}
/** @param {PointerEvent} e */
function onPointerMove(e) {
if (timer === null) return;
if (Math.abs(e.clientX - startX) > MOVE_TOL || Math.abs(e.clientY - startY) > MOVE_TOL) {
clear();
}
}
/** @param {Event} e */
function onContextMenu(e) {
if (firing) {
e.preventDefault();
firing = false;
}
}
node.addEventListener('pointerdown', onPointerDown);
node.addEventListener('pointermove', onPointerMove);
node.addEventListener('pointerup', clear);
node.addEventListener('pointerleave', clear);
node.addEventListener('pointercancel', clear);
node.addEventListener('contextmenu', onContextMenu);
return () => {
clear();
node.removeEventListener('pointerdown', onPointerDown);
node.removeEventListener('pointermove', onPointerMove);
node.removeEventListener('pointerup', clear);
node.removeEventListener('pointerleave', clear);
node.removeEventListener('pointercancel', clear);
node.removeEventListener('contextmenu', onContextMenu);
};
};
}
/** Whether long-pressing the given calendar cell can start a period. */
/** @param {{ date: string, status: string }} cell */
function canStartOn(cell) {
if (readOnly || !showEntry) return false;
if (ongoing) return false;
if (cell.status === 'period') return false;
return parseLocal(cell.date) <= todayMidnight;
}
async function endPeriod() {
if (!ongoing) return;
loading = true;
@@ -534,7 +654,7 @@
/** @param {string} id */
async function deletePeriod(id) {
if (!await confirm(t('delete_period_confirm', lang))) return;
if (!await confirm(t.delete_period_confirm)) return;
try {
const res = await fetch(`/api/fitness/period/${id}`, { method: 'DELETE' });
if (res.ok) {
@@ -590,7 +710,7 @@
{ownerName}
</span>
{:else}
{t('period_tracker', lang)}
{t.period_tracker}
{/if}
</h2>
@@ -599,29 +719,29 @@
{#if ongoing}
<div class="status-split">
<div class="status-main">
<span class="status-pill period-pill">{t('current_period', lang)}</span>
<span class="status-hero ongoing-hero">{t('period_day', lang)} {ongoingDay}</span>
<span class="status-pill period-pill">{t.current_period}</span>
<span class="status-hero ongoing-hero">{t.period_day} {ongoingDay}</span>
{#if showProjection && predictions.predictedEndOfOngoing}
<span class="status-detail">{t('predicted_end', lang)}</span>
<span class="status-detail">{t.predicted_end}</span>
<span class="status-relative">{relativeDate(predictions.predictedEndOfOngoing)}</span>
<span class="status-date">{formatDate(predictions.predictedEndOfOngoing)}</span>
{/if}
{#if showEntry && !readOnly}
<button class="end-btn" onclick={endPeriod} disabled={loading}>
<span class="end-btn-icon"><Check size={18} strokeWidth={2.5} /></span>
<span class="end-btn-label">{t('end_period', lang)}</span>
<span class="end-btn-label">{t.end_period}</span>
</button>
{/if}
</div>
{#if showProjection && nextCycle}
<div class="status-side">
<div class="status-side-item ovulation-accent">
<span class="status-side-label">{t('ovulation', lang)}</span>
<span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
</div>
<div class="status-side-item fertile-accent">
<span class="status-side-label">{t('fertile', lang)}</span>
<span class="status-side-label">{t.fertile}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileStart)}{formatDate(nextCycle.fertileEnd)}</span>
</div>
</div>
@@ -630,33 +750,33 @@
{:else if showProjection && nextCycle}
<div class="status-split">
<div class="status-main">
<span class="status-pill period-pill">{t('next_period', lang)}</span>
<span class="status-pill period-pill">{t.next_period}</span>
<span class="status-hero">{relativeRange(nextCycle.start, nextCycle.end)}</span>
<span class="status-date">{formatDate(nextCycle.start)}{formatDate(nextCycle.end)}</span>
{#if showEntry && !readOnly}
<button class="start-btn" onclick={startPeriod} disabled={loading}>
{t('start_period', lang)}
{t.start_period}
</button>
{/if}
</div>
<div class="status-side">
<div class="status-side-item ovulation-accent">
<span class="status-side-label">{t('ovulation', lang)}</span>
<span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
</div>
<div class="status-side-item fertile-accent">
<span class="status-side-label">{t('fertile', lang)}</span>
<span class="status-side-label">{t.fertile}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileStart)}{formatDate(nextCycle.fertileEnd)}</span>
</div>
</div>
</div>
{:else if showEntry}
<div class="status-block">
<span class="status-empty">{sorted.length === 0 ? t('no_period_data', lang) : t('no_active_period', lang)}</span>
<span class="status-empty">{sorted.length === 0 ? t.no_period_data : t.no_active_period}</span>
{#if !readOnly}
<button class="start-btn" onclick={startPeriod} disabled={loading}>
{t('start_period', lang)}
{t.start_period}
</button>
{/if}
</div>
@@ -685,20 +805,23 @@
</div>
<div class="cal-grid">
{#each calendarDays as cell}
{@const startable = canStartOn(cell)}
<span
class="cal-day {cell.status ? `s-${cell.status}` : ''} {cell.pos ? `p-${cell.pos}` : ''} {cell.edges}"
class:today={cell.date === todayStr}
class:overflow={cell.overflow}
class:startable
{@attach startable && longPress(() => promptStartPeriodOn(cell.date))}
>{cell.day}</span>
{/each}
</div>
<div class="cal-legend">
<span class="legend-item"><span class="legend-dot period"></span> {lang === 'de' ? 'Periode' : 'Period'}</span>
<span class="legend-item"><span class="legend-dot predicted"></span> {lang === 'de' ? 'Prognose' : 'Predicted'}</span>
<span class="legend-item"><span class="legend-dot fertile"></span> {t('fertile', lang)}</span>
<span class="legend-item"><span class="legend-dot peak-fertile"></span> {t('peak_fertility', lang)}</span>
<span class="legend-item"><span class="legend-dot ovulation"></span> {t('ovulation', lang)}</span>
<span class="legend-item"><span class="legend-dot luteal"></span> {t('luteal_phase', lang)}</span>
<span class="legend-item"><span class="legend-dot fertile"></span> {t.fertile}</span>
<span class="legend-item"><span class="legend-dot peak-fertile"></span> {t.peak_fertility}</span>
<span class="legend-item"><span class="legend-dot ovulation"></span> {t.ovulation}</span>
<span class="legend-item"><span class="legend-dot luteal"></span> {t.luteal_phase}</span>
</div>
</div>
@@ -707,17 +830,17 @@
{#if showProjection && completed.length >= 2}
<div class="cycle-stats">
<div class="cycle-stat">
<span class="cycle-stat-label">{t('cycle_length', lang)}</span>
<span class="cycle-stat-value">{Math.round(predictions.avgCycle)} {t('days', lang)}</span>
<span class="cycle-stat-label">{t.cycle_length}</span>
<span class="cycle-stat-value">{Math.round(predictions.avgCycle)} {t.days}</span>
{#if predictions.cycleVariance > 0}
<span class="cycle-stat-variance">± {predictions.cycleVariance} {t('days', lang)} (95% CI)</span>
<span class="cycle-stat-variance">± {predictions.cycleVariance} {t.days} (95% CI)</span>
{/if}
</div>
<div class="cycle-stat">
<span class="cycle-stat-label">{t('period_length', lang)}</span>
<span class="cycle-stat-value">{Math.round(predictions.avgPeriod)} {t('days', lang)}</span>
<span class="cycle-stat-label">{t.period_length}</span>
<span class="cycle-stat-value">{Math.round(predictions.avgPeriod)} {t.days}</span>
{#if predictions.periodVariance > 0}
<span class="cycle-stat-variance">± {predictions.periodVariance} {t('days', lang)} (95% CI)</span>
<span class="cycle-stat-variance">± {predictions.periodVariance} {t.days} (95% CI)</span>
{/if}
</div>
</div>
@@ -729,13 +852,13 @@
<div class="history">
<div class="history-share-row">
<button class="history-toggle" onclick={() => showHistory = !showHistory}>
<h3>{t('history', lang)}</h3>
<h3>{t.history}</h3>
<ChevronRight size={14} class={showHistory ? 'chevron open' : 'chevron'} />
</button>
<div class="share-bar">
{#if shareList.length > 0}
<div class="shared-avatars">
<span class="shared-label">{t('shared_with', lang)}</span>
<span class="shared-label">{t.shared_with}</span>
{#each shareList as user}
<div class="shared-avatar" title={user}>
<ProfilePicture username={user} size={28} />
@@ -743,7 +866,7 @@
{/each}
</div>
{/if}
<button class="share-btn" onclick={() => showShare = true} aria-label={t('share', lang)}>
<button class="share-btn" onclick={() => showShare = true} aria-label={t.share}>
<UserPlus size={16} />
</button>
</div>
@@ -753,7 +876,7 @@
<div class="history-header">
<button class="add-past-btn" onclick={() => showAddForm = !showAddForm}>
<Plus size={14} />
{t('add_past_period', lang)}
{t.add_past_period}
</button>
</div>
@@ -761,20 +884,20 @@
<div class="add-form">
<div class="add-row">
<label>
{t('period_start', lang)}
{t.period_start}
<DatePicker bind:value={addStart} max={todayStr} {lang} />
</label>
<label>
{t('period_end', lang)}
{t.period_end}
<DatePicker bind:value={addEnd} min={addStart} max={todayStr} {lang} />
</label>
</div>
<div class="add-actions">
<button class="save-btn" onclick={addPastPeriod} disabled={!addStart || loading}>
{t('save', lang)}
{t.save}
</button>
<button class="cancel-btn" onclick={() => { showAddForm = false; addStart = ''; addEnd = ''; }}>
{t('cancel', lang)}
{t.cancel}
</button>
</div>
</div>
@@ -786,20 +909,20 @@
<div class="history-item editing">
<div class="add-row">
<label>
{t('period_start', lang)}
{t.period_start}
<DatePicker bind:value={editStart} {lang} />
</label>
<label>
{t('period_end', lang)}
{t.period_end}
<DatePicker bind:value={editEnd} min={editStart} {lang} />
</label>
</div>
<div class="add-actions">
<button class="save-btn" onclick={saveEdit} disabled={!editStart || loading}>
{t('save', lang)}
{t.save}
</button>
<button class="cancel-btn" onclick={cancelEdit}>
{t('cancel', lang)}
{t.cancel}
</button>
</div>
</div>
@@ -811,12 +934,12 @@
{#if p.endDate}
{formatDate(p.endDate)}
{:else}
<span class="ongoing-badge">{t('ongoing', lang)}</span>
<span class="ongoing-badge">{t.ongoing}</span>
{/if}
</span>
{#if p.endDate}
{@const dur = Math.round((new Date(p.endDate).getTime() - new Date(p.startDate).getTime()) / 86400000) + 1}
<span class="history-dur">{dur} {t('days', lang)}</span>
<span class="history-dur">{dur} {t.days}</span>
{/if}
</div>
<div class="history-actions">
@@ -836,33 +959,33 @@
{:else}
<div class="empty-state">
<div class="share-bar">
<p>{t('no_period_data', lang)}</p>
<button class="share-btn" onclick={() => showShare = true} aria-label={t('share', lang)}>
<p>{t.no_period_data}</p>
<button class="share-btn" onclick={() => showShare = true} aria-label={t.share}>
<UserPlus size={16} />
</button>
</div>
<button class="add-past-btn" onclick={() => showAddForm = !showAddForm}>
<Plus size={14} />
{t('add_past_period', lang)}
{t.add_past_period}
</button>
{#if showAddForm}
<div class="add-form">
<div class="add-row">
<label>
{t('period_start', lang)}
{t.period_start}
<DatePicker bind:value={addStart} max={todayStr} {lang} />
</label>
<label>
{t('period_end', lang)}
{t.period_end}
<DatePicker bind:value={addEnd} min={addStart} max={todayStr} {lang} />
</label>
</div>
<div class="add-actions">
<button class="save-btn" onclick={addPastPeriod} disabled={!addStart || loading}>
{t('save', lang)}
{t.save}
</button>
<button class="cancel-btn" onclick={() => { showAddForm = false; addStart = ''; addEnd = ''; }}>
{t('cancel', lang)}
{t.cancel}
</button>
</div>
</div>
@@ -880,7 +1003,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="share-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="share-modal-header">
<h3>{t('share', lang)}</h3>
<h3>{t.share}</h3>
<button class="share-modal-close" onclick={() => showShare = false}>
<X size={16} />
</button>
@@ -898,13 +1021,13 @@
{/each}
</div>
{:else}
<span class="share-empty">{t('no_shared', lang)}</span>
<span class="share-empty">{t.no_shared}</span>
{/if}
<form class="share-add" onsubmit={(e) => { e.preventDefault(); addShareUser(); }}>
<input
type="text"
bind:value={shareInput}
placeholder={t('add_user', lang)}
placeholder={t.add_user}
disabled={shareSaving}
/>
<button type="submit" class="share-add-btn" disabled={!shareInput.trim() || shareSaving}>
@@ -1189,6 +1312,37 @@
}
.cal-day.overflow { color: var(--color-text-tertiary); }
/* Long-press affordance: scale + colored ring grows during the hold. */
.cal-day.startable {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
transition: transform 100ms ease-out, box-shadow 100ms ease-out;
}
.cal-day.startable.long-pressing {
z-index: 2;
border-radius: 999px;
animation: longPressRing 600ms ease-out forwards;
}
@keyframes longPressRing {
from {
transform: scale(1);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--nord11) 70%, transparent);
}
to {
transform: scale(1.18);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--nord11) 70%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.cal-day.startable.long-pressing {
animation: none;
transform: scale(1.1);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--nord11) 70%, transparent);
}
}
/* --- Range shape: border-radius per position --- */
.cal-day.p-solo { border-radius: 16px; }
.cal-day.p-start { border-radius: 16px 0 0 16px; }
@@ -7,7 +7,7 @@
import Wheat from '@lucide/svelte/icons/wheat';
import { untrack } from 'svelte';
import { toast } from '$lib/js/toast.svelte';
import { t } from '$lib/js/fitnessI18n';
import { m } from '$lib/js/fitnessI18n';
import MealTypePicker from '$lib/components/fitness/MealTypePicker.svelte';
/** @typedef {import('$lib/server/roundOffScoring').ComboSuggestion} ComboSuggestion */
@@ -35,6 +35,7 @@
initialSuggestions = null,
onlogged = () => {},
} = $props();
const t = $derived(m[lang]);
const isEn = $derived(lang === 'en');
@@ -157,7 +158,7 @@
<div class="round-off-header">
<Sparkles size={16} />
<h3>{isEn ? 'Round off this day' : 'Tag abrunden'}</h3>
<span class="round-off-remaining">{Math.round(remainingKcal)} kcal {t('remaining', lang)}</span>
<span class="round-off-remaining">{Math.round(remainingKcal)} kcal {t.remaining}</span>
</div>
{#if loading}
+5 -4
View File
@@ -6,9 +6,10 @@
import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte';
import { page } from '$app/state';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang(page.url.pathname));
const t = $derived(m[lang]);
/**
* @type {{
@@ -97,16 +98,16 @@
{#if editable && onRemove}
<th class="col-remove"></th>
{/if}
<th class="col-set">{t('set_header', lang)}</th>
<th class="col-set">{t.set_header}</th>
{#if previousSets}
<th class="col-prev">{t('prev_header', lang)}</th>
<th class="col-prev">{t.prev_header}</th>
{/if}
{#each mainMetrics as metric (metric)}
<th class="col-metric">{timedHold && metric === 'duration' ? 'SEC' : METRIC_LABELS[metric]}</th>
{/each}
{#if editable && hasRpe}
<th class="col-at"></th>
<th class="col-rpe">{t('rpe', lang)}</th>
<th class="col-rpe">{t.rpe}</th>
{/if}
{#if editable}
<th class="col-check"></th>
@@ -3,9 +3,10 @@
import EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical';
import MapPin from '@lucide/svelte/icons/map-pin';
import { page } from '$app/state';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang(page.url.pathname));
const t = $derived(m[lang]);
/**
* @type {{
@@ -23,10 +24,10 @@
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / 86400000);
if (diffDays === 0) return t('today', lang);
if (diffDays === 1) return t('yesterday', lang);
if (diffDays < 7) return lang === 'en' ? `${diffDays} days ago` : `vor ${diffDays} Tagen`;
return d.toLocaleDateString(lang === 'en' ? 'en' : 'de', { month: 'short', day: 'numeric' });
if (diffDays === 0) return t.today;
if (diffDays === 1) return t.yesterday;
if (diffDays < 7) return t.days_ago_template.replace('{n}', String(diffDays));
return d.toLocaleDateString(lang, { month: 'short', day: 'numeric' });
}
</script>
@@ -52,12 +53,12 @@
<li>{ex.sets.length} &times; {exercise?.localName ?? ex.exerciseId}</li>
{/each}
{#if template.exercises.length > 4}
<li class="more">+{template.exercises.length - 4} {t('more', lang)}</li>
<li class="more">+{template.exercises.length - 4} {t.more}</li>
{/if}
</ul>
{/if}
{#if lastUsed}
<p class="last-used">{t('last_performed', lang)} {formatDate(lastUsed)}</p>
<p class="last-used">{t.last_performed} {formatDate(lastUsed)}</p>
{/if}
</div>
+4 -3
View File
@@ -5,9 +5,10 @@ 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/state';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang(page.url.pathname));
const t = $derived(m[lang]);
let { href, elapsed = '0:00', paused = false, syncStatus = 'idle', onPauseToggle,
restSeconds = 0, restTotal = 0, onRestAdjust = null, onRestSkip = null } = $props();
@@ -28,7 +29,7 @@ const restProgress = $derived(restTotal > 0 ? restSeconds / restTotal : 0);
class:rest-active={restActive}
role="button"
tabindex="0"
aria-label={t('active_workout', lang)}
aria-label={t.active_workout}
onclick={() => goto(href)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goto(href); } }}
>
@@ -54,7 +55,7 @@ const restProgress = $derived(restTotal > 0 ? restSeconds / restTotal : 0);
<button class="rest-adj" onclick={(e) => { e.stopPropagation(); onRestAdjust?.(30); }} aria-label="Add 30 seconds">+30s</button>
</div>
{:else}
<span class="fab-label">{t('active_workout', lang)}</span>
<span class="fab-label">{t.active_workout}</span>
<ChevronRight size={14} strokeWidth={2.4} class="fab-chevron" />
{/if}
</div>
@@ -2,6 +2,8 @@
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
import X from '@lucide/svelte/icons/x';
import { toast } from '$lib/js/toast.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
recipeName,
@@ -12,6 +14,8 @@
portions = '',
isEnglish = true,
} = $props();
const lang = $derived(/** @type {RecipesLang} */ (isEnglish ? 'en' : 'de'));
const t = $derived(m[lang]);
// Flatten ingredient sections into a flat array with indices
const flatIngredients = $derived.by(() => {
@@ -35,16 +39,16 @@
let useGrams = $state(false);
const labels = $derived({
addToLog: isEnglish ? 'Add to food log' : 'Zum Ernährungstagebuch',
portions: isEnglish ? 'Portions' : 'Portionen',
grams: isEnglish ? 'Grams' : 'Gramm',
meal: isEnglish ? 'Meal' : 'Mahlzeit',
breakfast: isEnglish ? 'Breakfast' : 'Frühstück',
lunch: isEnglish ? 'Lunch' : 'Mittagessen',
dinner: isEnglish ? 'Dinner' : 'Abendessen',
snack: isEnglish ? 'Snack' : 'Snack',
log: isEnglish ? 'Log' : 'Eintragen',
cancel: isEnglish ? 'Cancel' : 'Abbrechen',
addToLog: t.add_to_food_log,
portions: t.portions_label,
grams: t.grams_label,
meal: t.meal_label,
breakfast: t.breakfast,
lunch: t.lunch,
dinner: t.dinner,
snack: t.snack,
log: t.log_action,
cancel: t.cancel
});
// Parse portion count from recipe's portions string (e.g. "4 Portionen")
@@ -171,13 +175,13 @@
})
});
if (res.ok) {
toast.success(isEnglish ? 'Added to food log' : 'Zum Ernährungstagebuch hinzugefügt');
toast.success(t.added_to_food_log);
showDialog = false;
} else {
toast.error(isEnglish ? 'Failed to add' : 'Fehler beim Hinzufügen');
toast.error(t.add_failed);
}
} catch {
toast.error(isEnglish ? 'Failed to add' : 'Fehler beim Hinzufügen');
toast.error(t.add_failed);
} finally {
saving = false;
}
@@ -1,5 +1,7 @@
<script>
import TagChip from '$lib/components/recipes/TagChip.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
categories = [],
@@ -9,9 +11,9 @@
useAndLogic = true
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Category' : 'Kategorie');
const selectLabel = $derived(isEnglish ? 'Select category...' : 'Kategorie auswählen...');
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const label = $derived(t.category_nav);
const selectLabel = $derived(t.select_category_placeholder);
// Convert selected to array for OR mode, keep as single value for AND mode
const selectedArray = $derived(
@@ -74,7 +74,12 @@ const t: Record<string, Record<string, string>> = {
moveReferenceDownAria: 'Referenz nach unten verschieben',
removeReferenceAria: 'Referenz entfernen',
moveListUpAria: 'Liste nach oben verschieben',
moveListDownAria: 'Liste nach unten verschieben'
moveListDownAria: 'Liste nach unten verschieben',
notSet: 'Nicht gesetzt',
duration: 'Dauer',
temperature: 'Temperatur',
mode: 'Modus',
customModePlaceholder: 'oder eigenen Modus eingeben…'
},
en: {
preparation: 'Preparation:',
@@ -109,7 +114,12 @@ const t: Record<string, Record<string, string>> = {
moveReferenceDownAria: 'Move reference down',
removeReferenceAria: 'Remove reference',
moveListUpAria: 'Move list up',
moveListDownAria: 'Move list down'
moveListDownAria: 'Move list down',
notSet: 'Not set',
duration: 'Duration',
temperature: 'Temperature',
mode: 'Mode',
customModePlaceholder: 'or enter custom mode…'
}
};
@@ -1017,7 +1027,7 @@ h3{
{#if add_info.baking.mode}<span class="chip mode">{add_info.baking.mode}</span>{/if}
</span>
{:else if !bakingExpanded}
<span class="baking-summary muted">{lang === 'de' ? 'Nicht gesetzt' : 'Not set'}</span>
<span class="baking-summary muted">{t[lang].notSet}</span>
{/if}
<svg class="chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1027,7 +1037,7 @@ h3{
{#if bakingExpanded}
<div id="baking-fields-{lang}" class="baking-form">
<div class="baking-field">
<label for="baking-length-{lang}">{lang === 'de' ? 'Dauer' : 'Duration'}</label>
<label for="baking-length-{lang}">{t[lang].duration}</label>
<div class="input-wrap">
<input
id="baking-length-{lang}"
@@ -1041,7 +1051,7 @@ h3{
</div>
</div>
<div class="baking-field">
<label for="baking-temp-{lang}">{lang === 'de' ? 'Temperatur' : 'Temperature'}</label>
<label for="baking-temp-{lang}">{t[lang].temperature}</label>
<div class="input-wrap">
<input
id="baking-temp-{lang}"
@@ -1055,7 +1065,7 @@ h3{
</div>
</div>
<div class="baking-field mode-field">
<span class="mode-label">{lang === 'de' ? 'Modus' : 'Mode'}</span>
<span class="mode-label">{t[lang].mode}</span>
<div class="mode-chips">
{#each BAKING_MODES[lang] as mode}
<button
@@ -1070,7 +1080,7 @@ h3{
type="text"
class="mode-custom"
bind:value={add_info.baking.mode}
placeholder={lang === 'de' ? 'oder eigenen Modus eingeben…' : 'or enter custom mode…'}
placeholder={t[lang].customModePlaceholder}
autocomplete="off"
/>
</div>
@@ -1,5 +1,7 @@
<script>
import Toggle from '$lib/components/Toggle.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
enabled = false,
@@ -8,8 +10,8 @@
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Favorites' : 'Favoriten');
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const label = $derived(t.favorites);
// svelte-ignore state_referenced_locally
let checked = $state(enabled);
@@ -5,6 +5,8 @@
import SeasonFilter from './SeasonFilter.svelte';
import FavoritesFilter from './FavoritesFilter.svelte';
import LogicModeToggle from './LogicModeToggle.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
availableCategories = [],
@@ -27,6 +29,7 @@
onLogicModeToggle = () => {}
} = $props();
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const isEnglish = $derived(lang === 'en');
const months = $derived(isEnglish
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
@@ -132,7 +135,7 @@
<div class="filter-wrapper">
<button class="toggle-button" onclick={toggleFilters} type="button">
<span>{filtersOpen ? (isEnglish ? 'Hide Filters' : 'Filter ausblenden') : (isEnglish ? 'Show Filters' : 'Filter einblenden')}</span>
<span>{filtersOpen ? t.hide_filters : t.show_filters}</span>
<span class="arrow" class:open={filtersOpen}>▼</span>
</button>
@@ -3,12 +3,13 @@
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let { item, multiplier = 1, yeastId = 0, lang = 'de' } = $props();
const isEnglish = $derived(lang === 'en');
const toggleTitle = $derived(isEnglish
? 'Switch between fresh yeast and dry yeast'
: 'Zwischen Frischhefe und Trockenhefe wechseln');
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const toggleTitle = $derived(t.yeast_toggle_title);
// Get all current URL parameters to preserve state
const currentParams = $derived(browser ? new URLSearchParams(window.location.search) : page.url.searchParams);
+4 -2
View File
@@ -1,5 +1,7 @@
<script>
import TagChip from '$lib/components/recipes/TagChip.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
availableIcons = [],
@@ -9,9 +11,9 @@
useAndLogic = true
} = $props();
const isEnglish = $derived(lang === 'en');
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const label = 'Icon';
const selectLabel = $derived(isEnglish ? 'Select icon...' : 'Icon auswählen...');
const selectLabel = $derived(t.select_icon_placeholder);
// Convert selected to array for OR mode, keep as single value for AND mode
const selectedArray = $derived(
@@ -6,6 +6,8 @@ import { page } from '$app/state';
import HefeSwapper from './HefeSwapper.svelte';
import NutritionSummary from './NutritionSummary.svelte';
import AddToFoodLogButton from './AddToFoodLogButton.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let { data } = $props();
const isLoggedIn = $derived(!!data.session?.user);
const hasNutrition = $derived(!!data.nutritionMappings?.length);
@@ -123,23 +125,25 @@ const flattenedIngredients = $derived.by(() => {
// svelte-ignore state_referenced_locally
let multiplier = $state(data.multiplier || 1);
const isEnglish = $derived(data.lang === 'en');
const lang = $derived(/** @type {RecipesLang} */ (data.lang));
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en');
const labels = $derived({
portions: isEnglish ? 'Portions:' : 'Portionen:',
adjustAmount: isEnglish ? 'Adjust Amount:' : 'Menge anpassen:',
ingredients: isEnglish ? 'Ingredients' : 'Zutaten',
cakeForm: isEnglish ? 'Cake form' : 'Backform',
adjustForm: isEnglish ? 'Adjust cake form' : 'Backform anpassen',
round: isEnglish ? 'Round' : 'Rund',
rectangular: isEnglish ? 'Rectangular' : 'Rechteckig',
portions: t.portions,
adjustAmount: t.adjust_amount,
ingredients: t.ingredients,
cakeForm: t.cake_form,
adjustForm: t.adjust_cake_form,
round: t.round_form,
rectangular: t.rectangular_form,
gugelhupf: 'Gugelhupf',
diameter: isEnglish ? 'Diameter' : 'Durchmesser',
outerDiameter: isEnglish ? 'Outer Ø' : 'Aussen-Ø',
innerDiameter: isEnglish ? 'Inner Ø' : 'Innen-Ø',
width: isEnglish ? 'Width' : 'Breite',
length: isEnglish ? 'Length' : 'Länge',
factor: isEnglish ? 'Factor' : 'Faktor',
restoreDefault: isEnglish ? 'Restore default' : 'Standard wiederherstellen',
diameter: t.diameter,
outerDiameter: t.outer_diameter,
innerDiameter: t.inner_diameter,
width: t.width,
length: t.length,
factor: t.factor,
restoreDefault: t.restore_default
});
// Cake form scaling
@@ -176,18 +180,16 @@ const formMultiplier = $derived(
hasDefaultForm && defaultFormArea > 0 ? userFormArea / defaultFormArea : 1
);
// Track whether multiplier is driven by form or manual buttons
let formDriven = $state(false);
let cakeFormExpanded = $state(false);
// Effective multiplier consumed by ingredient/portion calculations.
// Base multiplier (pill buttons / custom input) stays independent of the
// cake-form scaling so the two factors are visually distinct.
const effectiveMultiplier = $derived(multiplier * formMultiplier);
function applyFormMultiplier() {
formDriven = true;
}
let cakeFormExpanded = $state(false);
/** @param {string} shape */
function pickShape(shape) {
userFormShape = shape;
applyFormMultiplier();
}
const isDefaultForm = $derived(
@@ -200,7 +202,7 @@ const isDefaultForm = $derived(
);
const cakeSummaryText = $derived.by(() => {
if (userFormShape === 'round') return `${userFormDiameter} cm ${isEnglish ? 'round' : 'rund'}`;
if (userFormShape === 'round') return `${userFormDiameter} cm ${t.round_lowercase}`;
if (userFormShape === 'rectangular') return `${userFormWidth}×${userFormLength} cm`;
if (userFormShape === 'gugelhupf') return `${userFormDiameter}/${userFormInnerDiameter} cm Gugelhupf`;
return '';
@@ -213,19 +215,8 @@ function resetCakeForm() {
userFormWidth = data.defaultForm.width || 20;
userFormLength = data.defaultForm.length || 30;
userFormInnerDiameter = data.defaultForm.innerDiameter || 8;
formDriven = false;
multiplier = 1;
updateUrl(1);
}
// Reactively update multiplier when form dimensions change and form is driving
$effect(() => {
if (formDriven) {
multiplier = formMultiplier;
updateUrl(multiplier);
}
});
/** @param {number} value */
function updateUrl(value) {
if (browser) {
@@ -248,6 +239,8 @@ const multiplierOptions = [
{ value: 3, label: '3x' }
];
const isCustomMultiplier = $derived(!multiplierOptions.some(o => o.value === multiplier));
// Calculate yeast IDs for each yeast ingredient
const yeastIds = $derived.by(() => {
/** @type {Record<string, number>} */
@@ -294,7 +287,6 @@ function handleMultiplierClick(event, value) {
if (browser) {
event.preventDefault();
multiplier = value;
formDriven = false;
updateUrl(value);
}
// If no JS, form will submit normally
@@ -306,7 +298,6 @@ function handleCustomInput(event) {
const value = parseFloat(/** @type {HTMLInputElement} */ (event.target).value);
if (!isNaN(value) && value > 0) {
multiplier = value;
formDriven = false;
updateUrl(value);
}
}
@@ -460,7 +451,7 @@ const nutritionFlatIngredients = $derived.by(() => {
flex-basis: 0;
flex-grow: 1;
padding-block: 1rem;
padding-inline: 2rem;
padding-inline: 1.25rem;
}
.ingredients_grid{
display: grid;
@@ -473,18 +464,21 @@ const nutritionFlatIngredients = $derived.by(() => {
}
.multipliers{
display: flex;
gap: 0.5rem;
gap: 0.3rem;
justify-content: center;
flex-wrap: wrap;
}
/* Size overrides for multiplier buttons */
.multipliers button{
min-width: 2em;
min-width: 1.8em;
font-size: 1.1rem;
border-radius: var(--radius-sm);
padding-inline: 0.35em;
}
/* Hover scale override - larger than default */
.multipliers :is(button, form):is(:hover, :focus-within){
/* Hover/focus on a whole pill (number button or custom-multiplier wrapper)
flips its background to primary; :focus-within covers focus on the
nested <input> / <button> inside the custom-multiplier pill. */
.multipliers :is(button, .custom-multiplier):is(:hover, :focus-within){
scale: 1.2;
background-color: var(--color-primary);
color: var(--color-text-on-primary);
@@ -496,15 +490,24 @@ const nutritionFlatIngredients = $derived.by(() => {
scale: 1.2 !important;
}
.custom-multiplier {
display: flex;
display: inline-flex;
align-items: center;
min-width: 2em;
min-width: 1.8em;
font-size: 1.1rem;
border-radius: var(--radius-sm);
padding: 1px 0.35em;
/* Whole pill behaves like one input zone — no cursor flicker between the
typing area and the trailing "x" suffix. */
cursor: text;
}
.custom-input {
width: 3em;
/* Grow with the typed value (Chromium 123+, Safari 18.4+); falls back to
the fixed width on older browsers. min/max keep the wrap-around tame. */
field-sizing: content;
width: 1.4em;
min-width: 1ch;
max-width: 4em;
padding: 0;
margin: 0;
border: none;
@@ -515,6 +518,10 @@ const nutritionFlatIngredients = $derived.by(() => {
outline: none;
box-shadow: none;
}
.custom-input::placeholder {
color: currentColor;
opacity: 0.55;
}
/* Remove number input arrows */
.custom-input::-webkit-outer-spin-button,
@@ -523,16 +530,24 @@ const nutritionFlatIngredients = $derived.by(() => {
margin: 0;
}
.custom-button {
padding: 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
.custom-suffix {
margin-left: 0.05em;
font-size: inherit;
cursor: pointer;
box-shadow: none;
color: inherit;
user-select: none;
}
/* Off-screen submit button used as the form's implicit submitter when the
user presses Enter inside the custom-multiplier input (no-JS path). */
.implicit-submit {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.cake-form {
@@ -753,7 +768,7 @@ const nutritionFlatIngredients = $derived.by(() => {
<div class=ingredients>
{#if data.portions}
<h3>{labels.portions}</h3>
{@html convertFloatsToFractions(multiplyFirstAndSecondNumbers(data.portions, multiplier))}
{@html convertFloatsToFractions(multiplyFirstAndSecondNumbers(data.portions, effectiveMultiplier))}
{/if}
<h3>{labels.adjustAmount}</h3>
@@ -763,10 +778,14 @@ const nutritionFlatIngredients = $derived.by(() => {
<input type="hidden" name={key} {value} />
{/if}
{/each}
<!-- Implicit submitter for no-JS Enter on the custom input. Must be the
first submit in tree order so it wins over the pill buttons. Has no
name/value, so the form submits with only the input's typed value. -->
<button type="submit" class="implicit-submit" tabindex="-1" aria-hidden="true"></button>
{#each multiplierOptions as opt}
<button type="submit" name="multiplier" value={opt.value} class="g-pill g-btn-light g-interactive" class:selected={multiplier === opt.value} onclick={(e) => handleMultiplierClick(e, opt.value)}>{@html opt.label}</button>
{/each}
<span class="custom-multiplier g-pill g-btn-light g-interactive">
<label class="custom-multiplier g-pill g-btn-light g-interactive" class:selected={isCustomMultiplier}>
<input
type="text"
name="multiplier"
@@ -774,11 +793,11 @@ const nutritionFlatIngredients = $derived.by(() => {
title="Enter a positive number (e.g., 2.5, 0.75, 3.14)"
placeholder="…"
class="custom-input"
value={!multiplierOptions.some(o => o.value === multiplier) ? multiplier : ''}
value={isCustomMultiplier ? multiplier : ''}
oninput={handleCustomInput}
/>
<button type="submit" class="custom-button">x</button>
</span>
<span class="custom-suffix" aria-hidden="true">x</span>
</label>
</form>
{#if hasDefaultForm}
@@ -795,7 +814,7 @@ const nutritionFlatIngredients = $derived.by(() => {
<span class="cake-form-summary">{cakeSummaryText}</span>
</span>
<span class="cake-form-toggle-right">
{#if formDriven && Math.abs(formMultiplier - 1) > 0.005}
{#if Math.abs(formMultiplier - 1) > 0.005}
<span class="cake-form-factor-badge">{formMultiplier.toFixed(2)}×</span>
{/if}
<svg class="cake-form-chevron" class:expanded={cakeFormExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
@@ -856,7 +875,7 @@ const nutritionFlatIngredients = $derived.by(() => {
<label class="input-wrap">
<span class="input-label">{labels.diameter}</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={userFormDiameter} oninput={applyFormMultiplier} />
<input type="number" min="1" step="1" bind:value={userFormDiameter} />
<span class="input-suffix">cm</span>
</span>
</label>
@@ -864,14 +883,14 @@ const nutritionFlatIngredients = $derived.by(() => {
<label class="input-wrap">
<span class="input-label">{labels.width}</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={userFormWidth} oninput={applyFormMultiplier} />
<input type="number" min="1" step="1" bind:value={userFormWidth} />
<span class="input-suffix">cm</span>
</span>
</label>
<label class="input-wrap">
<span class="input-label">{labels.length}</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={userFormLength} oninput={applyFormMultiplier} />
<input type="number" min="1" step="1" bind:value={userFormLength} />
<span class="input-suffix">cm</span>
</span>
</label>
@@ -879,14 +898,14 @@ const nutritionFlatIngredients = $derived.by(() => {
<label class="input-wrap">
<span class="input-label">{labels.outerDiameter}</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={userFormDiameter} oninput={applyFormMultiplier} />
<input type="number" min="1" step="1" bind:value={userFormDiameter} />
<span class="input-suffix">cm</span>
</span>
</label>
<label class="input-wrap">
<span class="input-label">{labels.innerDiameter}</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={userFormInnerDiameter} oninput={applyFormMultiplier} />
<input type="number" min="1" step="1" bind:value={userFormInnerDiameter} />
<span class="input-suffix">cm</span>
</span>
</label>
@@ -907,7 +926,7 @@ const nutritionFlatIngredients = $derived.by(() => {
{#each flattenedIngredients as list, listIndex}
{#if list.name}
{#if list.isReference}
<h3><a href="{list.short_name}?multiplier={multiplier * (list.baseMultiplier || 1)}">{@html list.name}</a></h3>
<h3><a href="{list.short_name}?multiplier={effectiveMultiplier * (list.baseMultiplier || 1)}">{@html list.name}</a></h3>
{:else}
<h3>{@html list.name}</h3>
{/if}
@@ -915,12 +934,12 @@ const nutritionFlatIngredients = $derived.by(() => {
{#if list.list}
<div class=ingredients_grid>
{#each list.list as item, ingredientIndex}
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div>
<div class=amount>{@html adjust_amount(item.amount, effectiveMultiplier)} {item.unit}</div>
<div class=name>
{@html item.name.replace("{{multiplier}}", isNaN(parseFloat(item.amount)) ? multiplier : multiplier * parseFloat(item.amount))}
{@html item.name.replace("{{multiplier}}", isNaN(parseFloat(item.amount)) ? effectiveMultiplier : effectiveMultiplier * parseFloat(item.amount))}
{#if item.name.toLowerCase() === "frischhefe" || item.name.toLowerCase() === "trockenhefe" || item.name.toLowerCase() === "fresh yeast" || item.name.toLowerCase() === "dry yeast"}
{@const yeastId = yeastIds[`${listIndex}-${ingredientIndex}`] ?? 0}
<HefeSwapper {item} {multiplier} {yeastId} lang={data.lang} />
<HefeSwapper {item} multiplier={effectiveMultiplier} {yeastId} lang={data.lang} />
{/if}
</div>
{/each}
@@ -933,7 +952,7 @@ const nutritionFlatIngredients = $derived.by(() => {
nutritionMappings={data.nutritionMappings}
sectionNames={nutritionSectionNames}
referencedNutrition={data.referencedNutrition || []}
{multiplier}
multiplier={effectiveMultiplier}
portions={data.portions}
isEnglish={isEnglish}
>
@@ -5,6 +5,8 @@ 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 { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let { data } = $props();
// svelte-ignore state_referenced_locally
@@ -99,16 +101,18 @@ const flattenedInstructions = $derived.by(() => {
return flattenInstructionReferences(data.instructions, lang);
});
const isEnglish = $derived(data.lang === 'en');
const lang = $derived(/** @type {RecipesLang} */ (data.lang));
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en');
const labels = $derived({
preparation: isEnglish ? 'Preparation:' : 'Vorbereitung:',
bulkFermentation: isEnglish ? 'Bulk Fermentation:' : 'Stockgare:',
finalProof: isEnglish ? 'Final Proof:' : 'Stückgare:',
baking: isEnglish ? 'Baking:' : 'Backen:',
cooking: isEnglish ? 'Cooking:' : 'Kochen:',
onThePlate: isEnglish ? 'On the Plate:' : 'Auf dem Teller:',
instructions: isEnglish ? 'Instructions' : 'Zubereitung',
at: isEnglish ? 'at' : 'bei'
preparation: t.preparation_section,
bulkFermentation: t.bulk_fermentation,
finalProof: t.final_proof,
baking: t.baking_section,
cooking: t.cooking_section,
onThePlate: t.on_the_plate,
instructions: t.instructions_label,
at: t.at_temp
});
</script>
<style>
@@ -1,4 +1,6 @@
<script>
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
useAndLogic = true,
@@ -6,10 +8,10 @@
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Filter Mode' : 'Filter-Modus');
const andLabel = $derived(isEnglish ? 'AND' : 'UND');
const orLabel = $derived(isEnglish ? 'OR' : 'ODER');
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const label = $derived(t.filter_mode);
const andLabel = $derived(t.and_label);
const orLabel = $derived(t.or_label);
// svelte-ignore state_referenced_locally
let checked = $state(useAndLogic);
@@ -1,8 +1,12 @@
<script>
import { createNutritionCalculator } from '$lib/js/nutrition.svelte';
import RingGraph from '$lib/components/fitness/RingGraph.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let { flatIngredients, nutritionMappings, sectionNames, referencedNutrition, multiplier, portions, isEnglish, actions } = $props();
const lang = $derived(/** @type {RecipesLang} */ (isEnglish ? 'en' : 'de'));
const t = $derived(m[lang]);
const nutrition = createNutritionCalculator(
() => flatIngredients,
@@ -43,20 +47,20 @@
});
const labels = $derived({
title: isEnglish ? 'Nutrition' : 'Nährwerte',
perPortion: isEnglish ? 'per portion' : 'pro Portion',
protein: isEnglish ? 'Protein' : 'Eiweiß',
fat: isEnglish ? 'Fat' : 'Fett',
carbs: isEnglish ? 'Carbs' : 'Kohlenh.',
fiber: isEnglish ? 'Fiber' : 'Ballaststoffe',
sugars: isEnglish ? 'Sugars' : 'Zucker',
saturatedFat: isEnglish ? 'Sat. Fat' : 'Ges. Fett',
details: isEnglish ? 'Details' : 'Details',
vitamins: isEnglish ? 'Vitamins' : 'Vitamine',
minerals: isEnglish ? 'Minerals' : 'Mineralstoffe',
coverage: isEnglish ? 'coverage' : 'Abdeckung',
unmapped: isEnglish ? 'Not tracked' : 'Nicht erfasst',
aminoAcids: isEnglish ? 'Amino Acids' : 'Aminosäuren',
title: t.nutrition,
perPortion: t.per_portion,
protein: t.protein,
fat: t.fat,
carbs: t.carbs,
fiber: t.fiber,
sugars: t.sugars,
saturatedFat: t.saturated_fat,
details: t.details,
vitamins: t.vitamins,
minerals: t.minerals,
coverage: t.coverage,
unmapped: t.not_tracked,
aminoAcids: t.amino_acids
});
const hasAminoAcids = $derived.by(() => {
@@ -195,33 +199,33 @@
<div class="detail-section">
<h4>{labels.minerals}</h4>
<div class="detail-row"><span>Calcium</span><span>{fmt(nutrition.totalMicros.calcium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Iron' : 'Eisen'}</span><span>{fmt(nutrition.totalMicros.iron / div)} mg</span></div>
<div class="detail-row"><span>{t.iron}</span><span>{fmt(nutrition.totalMicros.iron / div)} mg</span></div>
<div class="detail-row"><span>Magnesium</span><span>{fmt(nutrition.totalMicros.magnesium / div)} mg</span></div>
<div class="detail-row"><span>Potassium</span><span>{fmt(nutrition.totalMicros.potassium / div)} mg</span></div>
<div class="detail-row"><span>Sodium</span><span>{fmt(nutrition.totalMicros.sodium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Zinc' : 'Zink'}</span><span>{fmt(nutrition.totalMicros.zinc / div)} mg</span></div>
<div class="detail-row"><span>{t.zinc}</span><span>{fmt(nutrition.totalMicros.zinc / div)} mg</span></div>
</div>
{#if hasAminoAcids}
<div class="detail-section">
<h4>{labels.aminoAcids}</h4>
<div class="detail-row"><span>{isEnglish ? 'Leucine' : 'Leucin'}</span><span>{fmt(nutrition.totalAminoAcids.leucine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Isoleucine' : 'Isoleucin'}</span><span>{fmt(nutrition.totalAminoAcids.isoleucine / div)} g</span></div>
<div class="detail-row"><span>Valin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.valine / div)} g</span></div>
<div class="detail-row"><span>Lysin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.lysine / div)} g</span></div>
<div class="detail-row"><span>Methionin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.methionine / div)} g</span></div>
<div class="detail-row"><span>Phenylalanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.phenylalanine / div)} g</span></div>
<div class="detail-row"><span>Threonin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.threonine / div)} g</span></div>
<div class="detail-row"><span>{t.leucine}</span><span>{fmt(nutrition.totalAminoAcids.leucine / div)} g</span></div>
<div class="detail-row"><span>{t.isoleucine}</span><span>{fmt(nutrition.totalAminoAcids.isoleucine / div)} g</span></div>
<div class="detail-row"><span>{t.valine}</span><span>{fmt(nutrition.totalAminoAcids.valine / div)} g</span></div>
<div class="detail-row"><span>{t.lysine}</span><span>{fmt(nutrition.totalAminoAcids.lysine / div)} g</span></div>
<div class="detail-row"><span>{t.methionine}</span><span>{fmt(nutrition.totalAminoAcids.methionine / div)} g</span></div>
<div class="detail-row"><span>{t.phenylalanine}</span><span>{fmt(nutrition.totalAminoAcids.phenylalanine / div)} g</span></div>
<div class="detail-row"><span>{t.threonine}</span><span>{fmt(nutrition.totalAminoAcids.threonine / div)} g</span></div>
<div class="detail-row"><span>Tryptophan</span><span>{fmt(nutrition.totalAminoAcids.tryptophan / div)} g</span></div>
<div class="detail-row"><span>Histidin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.histidine / div)} g</span></div>
<div class="detail-row"><span>Arginin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.arginine / div)} g</span></div>
<div class="detail-row"><span>Alanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.alanine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Aspartic Acid' : 'Asparaginsäure'}</span><span>{fmt(nutrition.totalAminoAcids.asparticAcid / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Cysteine' : 'Cystein'}</span><span>{fmt(nutrition.totalAminoAcids.cysteine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Glutamic Acid' : 'Glutaminsäure'}</span><span>{fmt(nutrition.totalAminoAcids.glutamicAcid / div)} g</span></div>
<div class="detail-row"><span>Glycin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.glycine / div)} g</span></div>
<div class="detail-row"><span>Prolin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.proline / div)} g</span></div>
<div class="detail-row"><span>Serin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.serine / div)} g</span></div>
<div class="detail-row"><span>Tyrosin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.tyrosine / div)} g</span></div>
<div class="detail-row"><span>{t.histidine}</span><span>{fmt(nutrition.totalAminoAcids.histidine / div)} g</span></div>
<div class="detail-row"><span>{t.arginine}</span><span>{fmt(nutrition.totalAminoAcids.arginine / div)} g</span></div>
<div class="detail-row"><span>{t.alanine}</span><span>{fmt(nutrition.totalAminoAcids.alanine / div)} g</span></div>
<div class="detail-row"><span>{t.aspartic_acid}</span><span>{fmt(nutrition.totalAminoAcids.asparticAcid / div)} g</span></div>
<div class="detail-row"><span>{t.cysteine}</span><span>{fmt(nutrition.totalAminoAcids.cysteine / div)} g</span></div>
<div class="detail-row"><span>{t.glutamic_acid}</span><span>{fmt(nutrition.totalAminoAcids.glutamicAcid / div)} g</span></div>
<div class="detail-row"><span>{t.glycine}</span><span>{fmt(nutrition.totalAminoAcids.glycine / div)} g</span></div>
<div class="detail-row"><span>{t.proline}</span><span>{fmt(nutrition.totalAminoAcids.proline / div)} g</span></div>
<div class="detail-row"><span>{t.serine}</span><span>{fmt(nutrition.totalAminoAcids.serine / div)} g</span></div>
<div class="detail-row"><span>{t.tyrosine}</span><span>{fmt(nutrition.totalAminoAcids.tyrosine / div)} g</span></div>
</div>
{/if}
</div>
+6 -3
View File
@@ -3,6 +3,8 @@
import { browser } from '$app/environment';
import FilterPanel from './FilterPanel.svelte';
import { getCategories } from '$lib/js/categories';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
// Filter props for different contexts
let {
@@ -20,12 +22,13 @@
// Generate categories internally based on language
const categories = $derived(getCategories(lang));
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const isEnglish = $derived(lang === 'en');
const searchResultsUrl = $derived(isEnglish ? '/recipes/search' : '/rezepte/search');
const labels = $derived({
placeholder: isEnglish ? 'Search...' : 'Suche...',
searchTitle: isEnglish ? 'Search' : 'Suchen',
clearTitle: isEnglish ? 'Clear search' : 'Sucheintrag löschen'
placeholder: t.search_placeholder_short,
searchTitle: t.search_title,
clearTitle: t.clear_search_title
});
let searchQuery = $state('');
@@ -1,5 +1,7 @@
<script>
import TagChip from '$lib/components/recipes/TagChip.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
selectedSeasons = [],
@@ -8,9 +10,9 @@
months = []
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Season' : 'Saison');
const selectLabel = $derived(isEnglish ? 'Select season...' : 'Saison auswählen...');
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const label = $derived(t.season_nav);
const selectLabel = $derived(t.select_season_placeholder);
let inputValue = $state('');
let dropdownOpen = $state(false);
+4 -2
View File
@@ -1,5 +1,7 @@
<script>
import TagChip from '$lib/components/recipes/TagChip.svelte';
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
let {
availableTags = [],
@@ -8,9 +10,9 @@
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const t = $derived(m[/** @type {RecipesLang} */ (lang)]);
const label = 'Tags';
const addTagLabel = $derived(isEnglish ? 'Type or select tag...' : 'Tag eingeben oder auswählen...');
const addTagLabel = $derived(t.add_tag_placeholder);
// Filter out already selected tags
const unselectedTags = $derived(availableTags.filter(t => !selectedTags.includes(t)));
+4 -2
View File
@@ -1,5 +1,7 @@
<script>
import { m } from '$lib/js/recipesI18n';
let { item, ondelete, onedit, isEnglish = false } = $props();
const t = $derived(isEnglish ? m.en : m.de);
/** @param {string} url */
function getDomain(url) {
@@ -142,8 +144,8 @@
<div class="card">
<div class="accent"></div>
<button class="card-btn edit-btn" onclick={() => onedit(item)} aria-label={isEnglish ? 'Edit' : 'Bearbeiten'}></button>
<button class="card-btn delete-btn" onclick={() => ondelete(item._id)} aria-label={isEnglish ? 'Delete' : 'Löschen'}></button>
<button class="card-btn edit-btn" onclick={() => onedit(item)} aria-label={t.edit}></button>
<button class="card-btn delete-btn" onclick={() => ondelete(item._id)} aria-label={t.delete}></button>
<div class="body">
<p class="name">{item.name}</p>
{#if item.links?.length}
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
/** DE calendar UI strings — source of truth for the key set. */
export const de = {
today: "Heute",
calendar: "Liturgischer Kalender",
jumpToToday: "Zu heute",
prev: "Vorheriger Monat",
next: "Nächster Monat",
psalterWeek: "Psalterwoche",
cycle: "Lesejahr",
rite1969Long: "Römisches Messbuch 1969 (Ordentliche Form)",
rite1962Long: "Römisches Messbuch 1962 (Ausserordentliche Form)",
wipTitle: "In Arbeit",
wipBody: "Der Kalender für den alten Ritus (Messbuch 1962) ist noch nicht verfügbar. Bleib dran.",
rite1962DisclaimerTitle: "Genauigkeit wird noch geprüft",
rite1962DisclaimerBody: "Der Kalender für den Ritus von 1962 stammt aus divinum-officium-Daten und wird noch Tag für Tag mit maßgeblichen Quellen abgeglichen. Nur die in romcal enthaltenen Schweizer Diözesankalender werden angewendet; weitere Landes- oder Ortskalender sind noch nicht verfügbar.",
calendarVariant: "Kalender",
rite1969SwissNote: "romcal liefert für 1969 nur einen nationalen Schweizer Kalender — diözesane Unterkalender sind für diesen Ritus nicht verfügbar.",
} as const;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
/** DE calendar (1962) UI strings — source of truth for the key set. */
export const de = {
commemorations: "Kommemorationen",
octave: "Oktav",
octaveDay: "Tag",
vigilOf: "Vigil von",
transferredFrom: "Übertragen von",
source: "Quelle",
propers: "Messproprium",
stationChurch: "Stationskirche",
viewLatin: "Latein",
viewParallel: "Parallel",
viewVernacular: "Deutsch",
fallbackBadge: "Allioli",
fallbackHint: "Keine Übersetzung im Messbuch vorhanden. Text aus der Allioli-Bibelübersetzung an der angegebenen Stelle.",
} as const;
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const en = {
today: "Today",
calendar: "Liturgical Calendar",
jumpToToday: "Jump to today",
prev: "Previous month",
next: "Next month",
psalterWeek: "Psalter week",
cycle: "Sunday cycle",
rite1969Long: "Roman Missal of 1969 (Ordinary Form)",
rite1962Long: "Roman Missal of 1962 (Extraordinary Form)",
wipTitle: "Work in progress",
wipBody: "The calendar for the older rite (1962 Missal) is not yet available. Stay tuned.",
rite1962DisclaimerTitle: "Accuracy still being verified",
rite1962DisclaimerBody: "The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Only the Swiss diocesan propers shipped by romcal are applied; other national/local calendars are not yet available.",
calendarVariant: "Calendar",
rite1969SwissNote: "romcal ships only a national Swiss calendar for 1969 — diocesan sub-calendars are not available for this rite.",
} as const satisfies Record<keyof typeof de, string>;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de_1962';
export const en = {
commemorations: "Commemorations",
octave: "Octave",
octaveDay: "day",
vigilOf: "Vigil of",
transferredFrom: "Transferred from",
source: "Source",
propers: "Mass propers",
stationChurch: "Station church",
viewLatin: "Latin",
viewParallel: "Parallel",
viewVernacular: "English",
fallbackBadge: "Douay-Rheims",
fallbackHint: "Translation not provided in the missal. Text taken from the Douay-Rheims Bible at the cited reference.",
} as const satisfies Record<keyof typeof de, string>;
+20
View File
@@ -0,0 +1,20 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const la = {
today: "Hodie",
calendar: "Calendarium Liturgicum",
jumpToToday: "Ad hodiernum",
prev: "Mensis praecedens",
next: "Mensis sequens",
psalterWeek: "Hebdomada psalterii",
cycle: "Cyclus dominicalis",
rite1969Long: "Missale Romanum 1969 (Forma Ordinaria)",
rite1962Long: "Missale Romanum 1962 (Forma Extraordinaria)",
wipTitle: "In opere",
wipBody: "Calendarium ritus antiqui (Missale 1962) nondum paratum est. Exspecta paulisper.",
rite1962DisclaimerTitle: "Accuratio adhuc probanda",
rite1962DisclaimerBody: "Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Tantum calendaria propria dioecesium Helvetiae a romcal provisa adhibentur; cetera calendaria nationalia vel localia nondum praesto sunt.",
calendarVariant: "Calendarium",
rite1969SwissNote: "Pro anno 1969 romcal solum calendarium Helvetiae nationale praebet — calendaria dioecesana propria pro hoc ritu non adsunt.",
} as const satisfies Record<keyof typeof de, string>;
+18
View File
@@ -0,0 +1,18 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de_1962';
export const la = {
commemorations: "Commemorationes",
octave: "Octava",
octaveDay: "dies",
vigilOf: "Vigilia",
transferredFrom: "Translatum ex",
source: "Fons",
propers: "Propria Missæ",
stationChurch: "Statio",
viewLatin: "Latine",
viewParallel: "Parallelum",
viewVernacular: "Vernacula",
fallbackBadge: "Vulgata",
fallbackHint: "Interpretatio localis deest. Textus ex Biblia Sacra locis citatis.",
} as const satisfies Record<keyof typeof de, string>;
+37
View File
@@ -0,0 +1,37 @@
/** German common UI strings — source of truth for the key set. */
export const de = {
// Auth / user header
login: 'Anmelden',
// (main) homepage
welcome: 'Willkommen auf bocken.org',
pages: 'Seiten',
recipes: 'Rezepte',
family_photos: 'Familienbilder',
video_conferences: 'Videokonferenzen',
search_engine: 'Suchmaschine',
shopping: 'Einkauf',
family_tree: 'Stammbaum',
faith: 'Glaube',
documents: 'Dokumente',
audiobooks_podcasts: 'Hörbücher & Podcasts',
nutrition: 'Ernährung',
tasks: 'Aufgaben',
// Offline sync button
sync_for_offline: 'Offline speichern',
syncing: 'Synchronisiere…',
offline_ready: 'Offline bereit',
last_sync: 'Letzte Sync',
recipes_word: 'Rezepte',
sync_now: 'Jetzt synchronisieren',
clear_offline_data: 'Offline-Daten löschen',
// Date picker
select_date: 'Datum wählen',
today: 'Heute',
// Error view
error_label: 'Fehler'
} as const;
+37
View File
@@ -0,0 +1,37 @@
import type { de } from './de';
export const en = {
// Auth / user header
login: 'Login',
// (main) homepage
welcome: 'Welcome to bocken.org',
pages: 'Pages',
recipes: 'Recipes',
family_photos: 'Family Photos',
video_conferences: 'Video Conferences',
search_engine: 'Search Engine',
shopping: 'Shopping',
family_tree: 'Family Tree',
faith: 'Faith',
documents: 'Documents',
audiobooks_podcasts: 'Audiobooks & Podcasts',
nutrition: 'Nutrition',
tasks: 'Tasks',
// Offline sync button
sync_for_offline: 'Save for offline',
syncing: 'Syncing…',
offline_ready: 'Offline ready',
last_sync: 'Last sync',
recipes_word: 'recipes',
sync_now: 'Sync now',
clear_offline_data: 'Clear offline data',
// Date picker
select_date: 'Select date',
today: 'Today',
// Error view
error_label: 'Error'
} as const satisfies Record<keyof typeof de, string>;
+237
View File
@@ -0,0 +1,237 @@
/** Generated by scripts/split-i18n.ts. */
/** DE cospend UI strings — source of truth for the key set. */
export const de = {
cospend_title: "Cospend - Ausgabenteilung",
all_payments_title: "Alle Zahlungen",
settle_title: "Schulden begleichen",
recurring_title: "Wiederkehrende Zahlungen",
shopping_list_title: "Einkaufsliste",
payment_details: "Zahlungsdetails",
cospend: "Cospend",
settle_debts: "Schulden begleichen",
monthly_expenses_chart: "Monatliche Ausgaben nach Kategorie",
loading_monthly: "Monatliche Ausgaben werden geladen...",
loading_recent: "Letzte Aktivitäten werden geladen...",
recent_activity: "Letzte Aktivität",
clear_filter: "Filter löschen",
no_recent_in: "Keine Aktivität in",
paid_by: "Bezahlt von",
payment: "Zahlung",
loading_payments: "Zahlungen werden geladen...",
no_payments_yet: "Noch keine Zahlungen",
start_first_expense: "Füge deine erste geteilte Ausgabe hinzu",
add_first_payment: "Erste Zahlung hinzufügen",
settlement: "Ausgleich",
split_details: "Aufteilung",
owes: "schuldet",
owed: "bekommt",
even: "ausgeglichen",
previous: "← Zurück",
next: "Weiter →",
load_more: "Mehr laden",
loading_ellipsis: "Laden...",
delete_payment_confirm: "Diese Zahlung wirklich löschen?",
date: "Datum:",
paid_by_label: "Bezahlt von:",
created_by: "Erstellt von:",
category_label: "Kategorie:",
split_method_label: "Aufteilungsart:",
description: "Beschreibung",
exchange_rate: "Wechselkurs",
receipt: "Beleg",
receipt_image: "Belegbild",
remove_image: "Bild entfernen",
replace_image: "Bild ersetzen",
upload_receipt: "Beleg hochladen",
uploading_image: "Bild wird hochgeladen...",
file_too_large: "Dateigrösse muss unter 5MB sein",
invalid_image: "Bitte eine gültige Bilddatei wählen (JPEG, PNG, WebP)",
you: "Du",
close: "Schliessen",
no_splits: "Keine Aufteilung",
split_equal: "Gleichmässig aufgeteilt auf",
paid_full_by: "Vollständig bezahlt von",
personal_equal: "Persönliche Beträge + Gleichverteilung auf",
custom_split: "Individuelle Aufteilung auf",
people: "Personen",
settle_subtitle: "Zahlungen erfassen, um offene Schulden auszugleichen",
loading_debts: "Schuldeninformationen werden geladen...",
all_settled: "Alles beglichen!",
no_debts_msg: "Keine offenen Schulden. Alle sind ausgeglichen!",
back_to_dashboard: "Zurück zum Dashboard",
available_settlements: "Mögliche Ausgleiche",
money_owed_to_you: "Geld, das du bekommst",
owes_you: "schuldet dir",
receive_payment: "Zahlung empfangen",
money_you_owe: "Geld, das du schuldest",
you_owe: "du schuldest",
make_payment: "Zahlung leisten",
settlement_details: "Ausgleichsdetails",
settlement_amount: "Ausgleichsbetrag",
record_settlement: "Ausgleich erfassen",
recording_settlement: "Ausgleich wird erfasst...",
cancel: "Abbrechen",
settlement_type: "Ausgleichsart",
select_settlement: "Ausgleichsart wählen",
receive_from: "Empfangen",
from: "von",
pay_to: "Zahlen",
to: "an",
from_user: "Von Benutzer",
select_payer: "Zahler wählen",
to_user: "An Benutzer",
select_recipient: "Empfänger wählen",
settlement_amount_chf: "Ausgleichsbetrag (CHF)",
error_select_settlement: "Bitte einen Ausgleich wählen und Betrag eingeben",
error_valid_amount: "Bitte einen gültigen positiven Betrag eingeben",
settlement_payment: "Ausgleichszahlung",
recurring_subtitle: "Automatisiere deine regelmässigen geteilten Ausgaben",
show_active_only: "Nur aktive anzeigen",
loading_recurring: "Wiederkehrende Zahlungen werden geladen...",
no_recurring: "Keine wiederkehrenden Zahlungen gefunden",
no_recurring_desc: "Erstelle deine erste wiederkehrende Zahlung für regelmässige Ausgaben wie Miete, Nebenkosten oder Abos.",
active: "Aktiv",
inactive: "Inaktiv",
frequency: "Häufigkeit:",
next_execution: "Nächste Ausführung:",
last_executed: "Zuletzt ausgeführt:",
ends: "Endet:",
split_between: "Aufgeteilt zwischen:",
gets: "bekommt",
edit: "Bearbeiten",
pause: "Pausieren",
activate: "Aktivieren",
delete_: "Löschen",
delete_recurring_confirm: "Wiederkehrende Zahlung wirklich löschen",
items_done: "erledigt",
add_item_placeholder: "Artikel hinzufügen...",
empty_list: "Die Einkaufsliste ist leer",
clear_checked: "Erledigte entfernen",
share: "Teilen",
shared_links: "Geteilte Links",
share_desc: "Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.",
loading: "Laden...",
no_active_links: "Keine aktiven Links.",
remaining: "noch",
change: "Ändern",
copy_link: "Link kopieren",
create_new_link: "Neuen Link erstellen",
copied: "Kopiert",
expired: "abgelaufen",
ttl_1h: "1 Stunde",
ttl_6h: "6 Stunden",
ttl_24h: "24 Stunden",
ttl_3d: "3 Tage",
ttl_7d: "7 Tage",
kategorie: "Kategorie",
icon: "Icon",
search_icon: "Icon suchen...",
save: "Speichern",
saving: "Speichern...",
edit_name: "Name",
edit_qty: "Menge",
edit_qty_ph: "z.B. 3x, 500g, 1L",
your_balance: "Dein Saldo",
you_are_owed: "Du bekommst",
you_owe_balance: "Du schuldest",
all_even: "Alles ausgeglichen",
owes_you_balance: "schuldet dir",
you_owe_user: "du schuldest",
transaction: "Transaktion",
transactions: "Transaktionen",
debt_overview: "Schuldenübersicht",
loading_debt_breakdown: "Schuldenübersicht wird geladen...",
who_owes_you: "Wer dir schuldet",
you_owe_section: "Du schuldest",
total: "Gesamt",
freq_every_day: "Jeden Tag",
freq_every_week: "Jede Woche",
freq_every_month: "Jeden Monat",
freq_custom: "Benutzerdefiniert",
freq_unknown: "Unbekannte Häufigkeit",
today_at: "Heute um",
tomorrow_at: "Morgen um",
in_days_at: "In {days} Tagen um",
split_between_users: "Aufteilen zwischen",
predefined_note: "Aufteilung zwischen vordefinierten Benutzern:",
remove: "Entfernen",
add_user_placeholder: "Benutzer hinzufügen...",
add_user: "Benutzer hinzufügen",
split_method: "Aufteilungsmethode",
how_split: "Wie soll diese Zahlung aufgeteilt werden?",
split_5050: "50/50 teilen",
custom_split_amounts: "Individuelle Beträge",
personal_exceeds_total: "Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!",
is_owed: "bekommt",
error_prefix: "Fehler",
cat_groceries: "Lebensmittel",
cat_shopping: "Einkauf",
cat_travel: "Reise",
cat_restaurant: "Restaurant",
cat_utilities: "Nebenkosten",
cat_fun: "Freizeit",
cat_settlement: "Ausgleich",
add_payment_title: "Neue Zahlung",
add_payment_subtitle: "Neue geteilte Ausgabe oder wiederkehrende Zahlung erstellen",
edit_payment_title: "Zahlung bearbeiten",
edit_payment_subtitle: "Zahlungsdetails und Beleg bearbeiten",
edit_recurring_title: "Wiederkehrende Zahlung bearbeiten",
payment_details_section: "Zahlungsdetails",
title_label: "Titel *",
title_placeholder: "z.B. Abendessen im Restaurant",
description_label: "Beschreibung",
description_placeholder: "Weitere Details...",
category_star: "Kategorie *",
amount_label: "Betrag *",
payment_date: "Zahlungsdatum",
paid_by_form: "Bezahlt von",
make_recurring: "Als wiederkehrende Zahlung einrichten",
recurring_section: "Wiederkehrende Zahlung",
recurring_schedule: "Wiederkehrender Zeitplan",
frequency_label: "Häufigkeit *",
freq_daily: "Täglich",
freq_weekly: "Wöchentlich",
freq_monthly: "Monatlich",
freq_quarterly: "Vierteljährlich",
freq_yearly: "Jährlich",
start_date: "Startdatum *",
end_date_optional: "Enddatum (optional)",
end_date_hint: "Leer lassen für unbefristete Wiederholung",
next_execution_preview: "Nächste Ausführung",
status_label: "Status",
create_payment: "Zahlung erstellen",
save_changes: "Änderungen speichern",
delete_payment: "Zahlung löschen",
deleting: "Löschen...",
split_config: "Aufteilungskonfiguration",
split_method_form: "Aufteilungsart:",
equal_split: "Gleichmässige Aufteilung",
personal_equal_split: "Persönliche Beträge + Gleichverteilung",
custom_proportions: "Individuelle Anteile",
personal_amounts: "Persönliche Beträge",
personal_amounts_desc: "Persönliche Beträge für jeden Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.",
total_personal: "Persönliche Summe",
remainder_to_split: "Rest zum Aufteilen",
personal_exceeds: "Persönliche Beträge übersteigen den Gesamtbetrag!",
split_preview: "Aufteilungsvorschau",
conversion_hint: "Betrag wird anhand des Wechselkurses am Zahlungstag in CHF umgerechnet",
fetching_rate: "Wechselkurs wird abgerufen...",
exchange_rate_date: "Wechselkurs wird für dieses Datum abgerufen",
paid_in_full: "Vollständig bezahlt",
paid_in_full_for: "Vollständig bezahlt für",
paid_in_full_by_you: "Vollständig von dir bezahlt",
paid_in_full_by: "Vollständig bezahlt von",
cat_fruits_veg: "Obst & Gemüse",
cat_meat_fish: "Fleisch & Fisch",
cat_dairy: "Milchprodukte",
cat_bakery: "Brot & Backwaren",
cat_grains: "Pasta, Reis & Getreide",
cat_spices: "Gewürze & Saucen",
cat_drinks: "Getränke",
cat_sweets: "Süßes & Snacks",
cat_frozen: "Tiefkühl",
cat_household: "Haushalt",
cat_hygiene: "Hygiene & Körperpflege",
cat_other: "Sonstiges",
} as const;
+237
View File
@@ -0,0 +1,237 @@
/** Generated by scripts/split-i18n.ts. */
import type { de } from './de';
export const en = {
cospend_title: "Expenses - Expense Sharing",
all_payments_title: "All Payments",
settle_title: "Settle Debts",
recurring_title: "Recurring Payments",
shopping_list_title: "Shopping List",
payment_details: "Payment Details",
cospend: "Expenses",
settle_debts: "Settle Debts",
monthly_expenses_chart: "Monthly Expenses by Category",
loading_monthly: "Loading monthly expenses chart...",
loading_recent: "Loading recent activity...",
recent_activity: "Recent Activity",
clear_filter: "Clear filter",
no_recent_in: "No recent activity in",
paid_by: "Paid by",
payment: "Payment",
loading_payments: "Loading payments...",
no_payments_yet: "No payments yet",
start_first_expense: "Start by adding your first shared expense",
add_first_payment: "Add Your First Payment",
settlement: "Settlement",
split_details: "Split Details",
owes: "owes",
owed: "owed",
even: "even",
previous: "← Previous",
next: "Next →",
load_more: "Load More",
loading_ellipsis: "Loading...",
delete_payment_confirm: "Are you sure you want to delete this payment?",
date: "Date:",
paid_by_label: "Paid by:",
created_by: "Created by:",
category_label: "Category:",
split_method_label: "Split method:",
description: "Description",
exchange_rate: "Exchange rate",
receipt: "Receipt",
receipt_image: "Receipt Image",
remove_image: "Remove Image",
replace_image: "Replace Image",
upload_receipt: "Upload Receipt Image",
uploading_image: "Uploading image...",
file_too_large: "File size must be less than 5MB",
invalid_image: "Please select a valid image file (JPEG, PNG, WebP)",
you: "You",
close: "Close",
no_splits: "No splits",
split_equal: "Split equally among",
paid_full_by: "Paid in full by",
personal_equal: "Personal amounts + equal split among",
custom_split: "Custom split among",
people: "people",
settle_subtitle: "Record payments to settle outstanding debts between users",
loading_debts: "Loading debt information...",
all_settled: "All Settled!",
no_debts_msg: "No outstanding debts to settle. Everyone is even!",
back_to_dashboard: "Back to Dashboard",
available_settlements: "Available Settlements",
money_owed_to_you: "Money You're Owed",
owes_you: "owes you",
receive_payment: "Receive Payment",
money_you_owe: "Money You Owe",
you_owe: "you owe",
make_payment: "Make Payment",
settlement_details: "Settlement Details",
settlement_amount: "Settlement Amount",
record_settlement: "Record Settlement",
recording_settlement: "Recording Settlement...",
cancel: "Cancel",
settlement_type: "Settlement Type",
select_settlement: "Select settlement type",
receive_from: "Receive",
from: "from",
pay_to: "Pay",
to: "to",
from_user: "From User",
select_payer: "Select payer",
to_user: "To User",
select_recipient: "Select recipient",
settlement_amount_chf: "Settlement Amount (CHF)",
error_select_settlement: "Please select a settlement and enter an amount",
error_valid_amount: "Please enter a valid positive amount",
settlement_payment: "Settlement Payment",
recurring_subtitle: "Automate your regular shared expenses",
show_active_only: "Show active only",
loading_recurring: "Loading recurring payments...",
no_recurring: "No recurring payments found",
no_recurring_desc: "Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.",
active: "Active",
inactive: "Inactive",
frequency: "Frequency:",
next_execution: "Next execution:",
last_executed: "Last executed:",
ends: "Ends:",
split_between: "Split between:",
gets: "gets",
edit: "Edit",
pause: "Pause",
activate: "Activate",
delete_: "Delete",
delete_recurring_confirm: "Are you sure you want to delete the recurring payment",
items_done: "done",
add_item_placeholder: "Add item...",
empty_list: "The shopping list is empty",
clear_checked: "Remove checked",
share: "Share",
shared_links: "Shared Links",
share_desc: "Anyone with an active link can edit the shopping list.",
loading: "Loading...",
no_active_links: "No active links.",
remaining: "remaining",
change: "Change",
copy_link: "Copy link",
create_new_link: "Create new link",
copied: "Copied",
expired: "expired",
ttl_1h: "1 hour",
ttl_6h: "6 hours",
ttl_24h: "24 hours",
ttl_3d: "3 days",
ttl_7d: "7 days",
kategorie: "Category",
icon: "Icon",
search_icon: "Search icon...",
save: "Save",
saving: "Saving...",
edit_name: "Name",
edit_qty: "Amount",
edit_qty_ph: "e.g. 3x, 500g, 1L",
your_balance: "Your Balance",
you_are_owed: "You are owed",
you_owe_balance: "You owe",
all_even: "You're all even",
owes_you_balance: "owes you",
you_owe_user: "you owe",
transaction: "transaction",
transactions: "transactions",
debt_overview: "Debt Overview",
loading_debt_breakdown: "Loading debt breakdown...",
who_owes_you: "Who owes you",
you_owe_section: "You owe",
total: "Total",
freq_every_day: "Every day",
freq_every_week: "Every week",
freq_every_month: "Every month",
freq_custom: "Custom",
freq_unknown: "Unknown frequency",
today_at: "Today at",
tomorrow_at: "Tomorrow at",
in_days_at: "In {days} days at",
split_between_users: "Split Between Users",
predefined_note: "Splitting between predefined users:",
remove: "Remove",
add_user_placeholder: "Add user...",
add_user: "Add User",
split_method: "Split Method",
how_split: "How should this payment be split?",
split_5050: "Split 50/50",
custom_split_amounts: "Custom Split Amounts",
personal_exceeds_total: "Warning: Personal amounts exceed total payment amount!",
is_owed: "is owed",
error_prefix: "Error",
cat_groceries: "Groceries",
cat_shopping: "Shopping",
cat_travel: "Travel",
cat_restaurant: "Restaurant",
cat_utilities: "Utilities",
cat_fun: "Fun",
cat_settlement: "Settlement",
add_payment_title: "Add New Payment",
add_payment_subtitle: "Create a new shared expense or recurring payment",
edit_payment_title: "Edit Payment",
edit_payment_subtitle: "Modify payment details and receipt image",
edit_recurring_title: "Edit Recurring Payment",
payment_details_section: "Payment Details",
title_label: "Title *",
title_placeholder: "e.g., Dinner at restaurant",
description_label: "Description",
description_placeholder: "Additional details...",
category_star: "Category *",
amount_label: "Amount *",
payment_date: "Payment Date",
paid_by_form: "Paid by",
make_recurring: "Make this a recurring payment",
recurring_section: "Recurring Payment",
recurring_schedule: "Recurring Schedule",
frequency_label: "Frequency *",
freq_daily: "Daily",
freq_weekly: "Weekly",
freq_monthly: "Monthly",
freq_quarterly: "Quarterly",
freq_yearly: "Yearly",
start_date: "Start Date *",
end_date_optional: "End Date (optional)",
end_date_hint: "Leave empty for indefinite recurring",
next_execution_preview: "Next Execution",
status_label: "Status",
create_payment: "Create payment",
save_changes: "Save changes",
delete_payment: "Delete Payment",
deleting: "Deleting...",
split_config: "Split Configuration",
split_method_form: "Split Method:",
equal_split: "Equal Split",
personal_equal_split: "Personal + Equal Split",
custom_proportions: "Custom Proportions",
personal_amounts: "Personal Amounts",
personal_amounts_desc: "Enter personal amounts for each user. The remainder will be split equally.",
total_personal: "Total Personal",
remainder_to_split: "Remainder to Split",
personal_exceeds: "Personal amounts exceed total payment amount!",
split_preview: "Split Preview",
conversion_hint: "Amount will be converted to CHF using exchange rates for the payment date",
fetching_rate: "Fetching exchange rate...",
exchange_rate_date: "Exchange rate will be fetched for this date",
paid_in_full: "Paid in Full",
paid_in_full_for: "Paid in Full for",
paid_in_full_by_you: "Paid in Full by You",
paid_in_full_by: "Paid in Full by",
cat_fruits_veg: "Fruits & Vegetables",
cat_meat_fish: "Meat & Fish",
cat_dairy: "Dairy",
cat_bakery: "Bread & Bakery",
cat_grains: "Pasta, Rice & Grains",
cat_spices: "Spices & Sauces",
cat_drinks: "Beverages",
cat_sweets: "Sweets & Snacks",
cat_frozen: "Frozen",
cat_household: "Household",
cat_hygiene: "Hygiene & Body Care",
cat_other: "Other",
} as const satisfies Record<keyof typeof de, string>;
+73
View File
@@ -0,0 +1,73 @@
/** German faith UI strings — source of truth for the key set. */
export const de = {
title: 'Glaube',
description:
'Hier findet man einige Gebete und einen interaktiven Rosenkranz zum katholischen Glauben. Ein Fokus auf Latein und den alten Ritus wird zu bemerken sein.',
prayers: 'Gebete',
rosary: 'Rosenkranz',
apologetics: 'Apologetik',
calendar: 'Kalender',
catechesis: 'Katechese',
in_season: 'Zur Zeit',
// Apologetik
objections: 'Einwände',
voices_answering: 'Die antwortenden Stimmen',
objection_label: 'EINWAND',
answered_by: 'Beantwortet von',
alex_pick: "Alex' Wahl",
arguments_title: 'Apologetik',
evidences: 'Belege',
positive_case: 'Positives',
// Streak counters (rosary, angelus)
day_singular: 'Tag',
day_plural: 'Tage',
prayed: 'Gebetet',
prayed_today: 'Heute gebetet',
mark_prayer: 'Gebet als gebetet markieren',
done_today: 'Heute fertig',
morning: 'Morgens',
noon: 'Mittags',
evening: 'Abends',
// Bible modal
close: 'Schliessen',
loading: 'Lädt…',
no_verses_found: 'Keine Verse gefunden',
no_verse_data: 'Keine Versdaten verfügbar',
// Language-availability notice (catechesis is German-only)
only_german_pre: 'Diese Katechese ist nur auf ',
only_german_link: 'Deutsch',
only_german_post: ' verfügbar.',
// Prayers index
prayers_description: 'Katholische Gebete auf Deutsch und Latein.',
sign_of_cross: 'Das heilige Kreuzzeichen',
pater_noster: 'Paternoster',
fatima_prayer: 'Das Fatimagebet',
st_michael_prayer: 'Gebet zum hl. Erzengel Michael',
bruder_klaus_prayer: 'Bruder Klaus Gebet',
st_joseph_prayer: 'Josephgebet des hl. Papst Pius X',
the_confiteor: 'Das Confiteor',
postcommunio_prayers: 'Nachkommuniongebete',
prayer_before_crucifix: 'Gebet vor einem Kruzifix',
guardian_angel_prayer: 'Schutzengel-Gebet',
apostles_creed: 'Apostolisches Glaubensbekenntnis',
search_prayers: 'Gebete suchen…',
clear_search: 'Suche löschen',
text_match: 'Treffer im Gebetstext',
filter_by_category: 'Nach Kategorie filtern',
all_categories: 'Alle',
eastertide_badge: 'Osterzeit',
// Prayer detail page — alternate names for credo/aveMaria (English differs)
nicene_creed: 'Credo',
hail_mary: 'Ave Maria',
// Painting titles on the prayer detail page
painting_coronation_virgin: 'Die Krönung der Jungfrau',
painting_annunciation: 'Die Verkündigung'
} as const;
+73
View File
@@ -0,0 +1,73 @@
import type { de } from './de';
export const en = {
title: 'Faith',
description:
'Here you will find some prayers and an interactive rosary for the Catholic faith. A focus on Latin and the older rite will be noticeable.',
prayers: 'Prayers',
rosary: 'Rosary',
apologetics: 'Apologetics',
calendar: 'Calendar',
catechesis: 'Catechesis',
in_season: 'In season',
// Apologetik
objections: 'Objections',
voices_answering: 'The voices that answer',
objection_label: 'OBJECTION',
answered_by: 'Answered by',
alex_pick: "Alex's pick",
arguments_title: 'Arguments',
evidences: 'Evidences',
positive_case: 'Positive case',
// Streak counters
day_singular: 'Day',
day_plural: 'Days',
prayed: 'Prayed',
prayed_today: 'Prayed today',
mark_prayer: 'Mark prayer as prayed',
done_today: 'Done today',
morning: 'Morning',
noon: 'Noon',
evening: 'Evening',
// Bible modal
close: 'Close',
loading: 'Loading…',
no_verses_found: 'No verses found',
no_verse_data: 'No verse data available',
// Language-availability notice (catechesis is German-only)
only_german_pre: 'This catechesis is only available in ',
only_german_link: 'German',
only_german_post: '.',
// Prayers index
prayers_description: 'Catholic prayers in Latin and English.',
sign_of_cross: 'The Sign of the Cross',
pater_noster: 'Our Father',
fatima_prayer: 'Fatima Prayer',
st_michael_prayer: 'Prayer to St. Michael the Archangel',
bruder_klaus_prayer: 'Prayer of St. Nicholas of Flüe',
st_joseph_prayer: 'Prayer to St. Joseph by Pope St. Pius X',
the_confiteor: 'The Confiteor',
postcommunio_prayers: 'Postcommunio Prayers',
prayer_before_crucifix: 'Prayer Before a Crucifix',
guardian_angel_prayer: 'Guardian Angel Prayer',
apostles_creed: "Apostles' Creed",
search_prayers: 'Search prayers…',
clear_search: 'Clear search',
text_match: 'Match in prayer text',
filter_by_category: 'Filter by category',
all_categories: 'All',
eastertide_badge: 'Eastertide',
// Prayer detail page
nicene_creed: 'Nicene Creed',
hail_mary: 'Hail Mary',
// Painting titles
painting_coronation_virgin: 'Coronation of the Virgin',
painting_annunciation: 'The Annunciation'
} as const satisfies Record<keyof typeof de, string>;
+73
View File
@@ -0,0 +1,73 @@
import type { de } from './de';
export const la = {
title: 'Fides',
description:
'Hic invenies orationes et rosarium interactivum fidei catholicae.',
prayers: 'Orationes',
rosary: 'Rosarium Vivum',
apologetics: 'Apologetica',
calendar: 'Calendarium',
catechesis: 'Catechesis',
in_season: 'Tempore',
// Apologetik
objections: 'Obiectiones',
voices_answering: 'Voces respondentes',
objection_label: 'OBIECTIO',
answered_by: 'Respondetur a',
alex_pick: 'Alexandri delectus',
arguments_title: 'Apologia',
evidences: 'Argumenta',
positive_case: 'Argumenta pro',
// Streak counters — Latin "Dies" is invariant
day_singular: 'Dies',
day_plural: 'Dies',
prayed: 'Oravi',
prayed_today: 'Hodie oravi',
mark_prayer: 'Orationem notatam fac',
done_today: 'Hodie completa',
morning: 'Mane',
noon: 'Meridie',
evening: 'Vespere',
// Bible modal — not used in Latin context but the dict requires every key
close: 'Claude',
loading: 'Carico…',
no_verses_found: 'Versus non inventi',
no_verse_data: 'Nulli versus praesto',
// Language-availability notice (catechesis is German-only)
only_german_pre: 'Haec catechesis tantum in ',
only_german_link: 'lingua Germanica',
only_german_post: ' praesto est.',
// Prayers index
prayers_description: 'Orationes catholicae in lingua Latina.',
sign_of_cross: 'Signum Crucis',
pater_noster: 'Pater Noster',
fatima_prayer: 'Oratio Fatimensis',
st_michael_prayer: 'Oratio ad S. Michaëlem Archangelum',
bruder_klaus_prayer: 'Oratio S. Nicolai de Flüe',
st_joseph_prayer: 'Oratio S. Iosephi a S. Papa Pio X',
the_confiteor: 'Confiteor',
postcommunio_prayers: 'Orationes post Communionem',
prayer_before_crucifix: 'Oratio ante Crucifixum',
guardian_angel_prayer: 'Angele Dei',
apostles_creed: 'Symbolum Apostolorum',
search_prayers: 'Orationes quaerere…',
clear_search: 'Quaestionem delere',
text_match: 'In textu orationis',
filter_by_category: 'Filtrare per categoriam',
all_categories: 'Omnia',
eastertide_badge: 'Tempus Paschale',
// Prayer detail page — Latin uses the Latin form invariantly
nicene_creed: 'Credo',
hail_mary: 'Ave Maria',
// Painting titles — Latin reuses German fallback
painting_coronation_virgin: 'Die Krönung der Jungfrau',
painting_annunciation: 'Die Verkündigung'
} as const satisfies Record<keyof typeof de, string>;
+392
View File
@@ -0,0 +1,392 @@
/** Generated by scripts/split-fitness-i18n.ts. */
/** German fitness UI strings — source of truth for the key set. */
export const de = {
save: "Speichern",
saving: "Speichern…",
cancel: "ABBRECHEN",
delete_: "Löschen",
edit: "Bearbeiten",
loading: "Laden…",
set: "Satz",
sets: "Sätze",
exercise: "Übung",
exercises_word: "Übungen",
kg: "kg",
km: "km",
min: "Min",
stats_title: "Statistik",
workout_singular: "Training",
workouts_plural: "Trainings",
lifted: "Gehoben",
est_kcal: "Gesch. kcal",
burned: "Verbrannt",
kcal_set_profile: "Geschlecht & Grösse unter",
covered: "Zurückgelegt",
workouts_per_week: "Trainings pro Woche",
sex: "Geschlecht",
male: "Männlich",
female: "Weiblich",
height: "Grösse (cm)",
birth_year: "Geburtsjahr",
no_workout_data: "Noch keine Trainingsdaten vorhanden.",
weight: "Gewicht",
history_title: "Verlauf",
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
load_more: "Mehr laden",
date: "Datum",
time: "Uhrzeit",
duration_min: "Dauer (Min)",
notes: "Notizen",
notes_placeholder: "Trainingsnotizen...",
gps_track_stored: "GPS-Track gespeichert",
add_set: "+ SATZ HINZUFÜGEN",
add_exercise: "+ ÜBUNG HINZUFÜGEN",
splits: "Splits",
pace: "TEMPO",
upload_gpx: "GPX hochladen",
uploading: "Hochladen...",
download_gpx: "GPX herunterladen",
elevation: "Höhenprofil",
elevation_unit: "m",
elevation_gain: "Anstieg",
elevation_loss: "Abstieg",
cadence: "Kadenz",
cadence_unit: "spm",
cadence_permission_missing: "Kadenz deaktiviert — Aktivitätserkennung in den Einstellungen erlauben",
personal_records: "Persönliche Rekorde",
delete_session_confirm: "Dieses Training löschen?",
remove_gps_confirm: "GPS-Track von dieser Übung entfernen?",
recalc_title: "Volumen, PRs und GPS-Vorschauen neu berechnen",
next_in_schedule: "Nächstes im Plan",
start_empty_workout: "leeres Training",
templates: "Vorlagen",
schedule: "Zeitplan",
my_templates: "Meine Vorlagen",
no_templates_yet: "Noch keine Vorlagen. Stöbere in der Bibliothek oder erstelle deine eigene.",
template_library: "Vorlagen-Bibliothek",
browse_library: "Bibliothek durchsuchen",
template_added: "Vorlage hinzugefügt",
edit_template: "Vorlage bearbeiten",
new_template: "Neue Vorlage",
template_name_placeholder: "Vorlagenname",
add_set_lower: "+ Satz hinzufügen",
add_exercise_btn: "Übung hinzufügen",
save_template: "Vorlage speichern",
workout_schedule: "Trainingsplan",
schedule_hint: "Wähle Vorlagen und ordne sie an. Nach Abschluss eines Trainings wird das nächste in der Rotation vorgeschlagen.",
available_templates: "Verfügbare Vorlagen",
all_templates_scheduled: "Alle Vorlagen sind im Zeitplan",
save_schedule: "Zeitplan speichern",
start_workout: "Training starten",
delete_template: "Löschen",
workout_complete: "Training abgeschlossen",
workout_saved_offline: "Offline gespeichert — wird bei Verbindung synchronisiert.",
duration: "Dauer",
tonnage: "Tonnage",
distance: "Distanz",
exercises_heading: "Übungen",
volume: "Volumen",
avg: "Ø",
update_template: "Vorlage aktualisieren",
template_updated: "Vorlage aktualisiert",
template_diff_desc: "Gewichte oder Wiederholungen weichen von der Vorlage ab:",
updating: "Aktualisieren...",
view_workout: "TRAINING ANSEHEN",
done: "FERTIG",
workout_name_placeholder: "Trainingsname",
cancel_workout: "TRAINING ABBRECHEN",
finish: "BEENDEN",
new_set_added: "neuer Satz",
new_sets_added: "neue Sätze",
exercises_title: "Übungen",
search_exercises: "Übungen suchen…",
no_exercises_match: "Keine Übungen gefunden.",
type_any: "Alle Arten",
type_weights: "Kraft",
type_stretches: "Dehnen",
stretch_pill: "Dehnung",
strength_pill: "Kraft",
cardio_pill: "Cardio",
plyo_pill: "Plyo",
about: "INFO",
history_tab: "VERLAUF",
charts: "DIAGRAMME",
records: "REKORDE",
instructions: "Anleitung",
no_history_yet: "Noch kein Verlauf für diese Übung.",
est_1rm: "GESCH. 1RM",
best_set_1rm: "Bester Satz (Gesch. 1RM)",
best_set_max: "Bester Satz (Max. Gewicht)",
total_volume: "Gesamtvolumen",
not_enough_data: "Noch nicht genug Daten für Diagramme.",
estimated_1rm: "Geschätztes 1RM",
max_volume: "Max. Volumen",
max_weight: "Max. Gewicht",
rep_records: "Wiederholungsrekorde",
reps: "WDH",
best_performance: "BESTLEISTUNG",
measure_title: "Messen",
profile: "Profil",
profile_setup_cta: "Größe & Geburtsjahr eintragen, um BMI, TDEE und Kalorienbilanz freizuschalten.",
set_up_profile: "Einrichten",
edit_profile: "Profil bearbeiten",
dismiss: "Verwerfen",
new_measurement: "Neue Messung",
edit_measurement: "Messung bearbeiten",
weight_kg: "Gewicht (kg)",
body_fat: "Körperfett %",
calories_kcal: "Kalorien (kcal)",
body_parts_cm: "Körpermasse (cm)",
neck: "Hals",
shoulders: "Schultern",
chest: "Brust",
l_bicep: "L Bizeps",
r_bicep: "R Bizeps",
l_forearm: "L Unterarm",
r_forearm: "R Unterarm",
waist: "Taille",
hips: "Hüfte",
l_thigh: "L Oberschenkel",
r_thigh: "R Oberschenkel",
l_calf: "L Wade",
r_calf: "R Wade",
biceps: "Bizeps",
forearms: "Unterarme",
thighs: "Oberschenkel",
calves: "Waden",
measure_tip_neck: "Direkt unter dem Adamsapfel, Band parallel zum Boden.",
measure_tip_shoulders: "Breiteste Stelle über die Schultern, Arme entspannt hängend.",
measure_tip_chest: "In Brustwarzenhöhe nach normalem Ausatmen, Band waagerecht.",
measure_tip_biceps: "Arm angespannt im Peak; um die dickste Stelle messen.",
measure_tip_forearms: "Breiteste Stelle unterhalb des Ellenbogens, Arm entspannt.",
measure_tip_waist: "In Nabelhöhe, locker — nicht einziehen.",
measure_tip_hips: "Um die breiteste Stelle des Gesäßes.",
measure_tip_thighs: "Mittig zwischen Leistenfalte und Knie.",
measure_tip_calves: "Breiteste Stelle, beidseitig belastet stehend.",
save_measurement: "Messung speichern",
update_measurement: "Messung aktualisieren",
measure_body_parts: "Körpermasse erfassen",
measure_body_parts_sub: "Geführter Ablauf — ein Körperteil nach dem anderen.",
last_measured: "Zuletzt gemessen",
no_measurements_yet: "Noch keine Messungen",
step_n_of_m: "Schritt {n} von {m}",
over_time: "{label} im Verlauf",
first_measurement_hint: "Erste Messung — dein Wert erscheint hier.",
running_totals: "Laufende Übersicht",
review_save: "Prüfen & speichern",
ready_to_save: "Bereit zum Speichern",
review_numbers: "Prüfe deine Werte unten.",
skip: "Auslassen",
next: "Weiter",
back: "Zurück",
review: "Prüfen",
edit_again: "Erneut bearbeiten",
exit: "Schließen",
same_both_sides: "Auf beiden Seiten gleich",
copy_l_to_r: "L → R übernehmen",
copy_l_to_r_before: "L",
copy_l_to_r_after: "R übernehmen",
kbd_nav: "Navigation",
kbd_next: "weiter",
kbd_skip: "auslassen",
kbd_wheel: "±0,1",
kbd_hint: "? drücken für Tastenkürzel",
no_body_parts_selected: "Bitte mindestens einen Wert eingeben.",
today_short: "heute",
latest: "Aktuell",
body_fat_short: "Körperfett",
calories: "Kalorien",
body_parts: "Körpermasse",
body_measurements_only: "Nur Körpermasse",
delete_measurement_confirm: "Diese Messung löschen?",
general: "Allgemein",
body_fat_pct: "Körperfett (%)",
history: "Verlauf",
past_measurements: "Frühere Messungen",
show_more: "Mehr anzeigen",
overwrite_title: "Bestehende Werte überschreiben?",
overwrite_message: "Für dieses Datum sind bereits Werte erfasst: {fields}. Überschreiben?",
overwrite_confirm: "Überschreiben",
same_as_last: "Wie zuletzt",
set_header: "SATZ",
prev_header: "VORH",
rpe: "RPE",
picker_title: "Übung hinzufügen",
no_exercises_found: "Keine Übungen gefunden",
last_performed: "Zuletzt durchgeführt:",
today: "Heute",
yesterday: "Gestern",
days_ago: "Tagen",
more: "weitere",
active_workout: "Aktives Training",
streak: "Serie",
streak_weeks: "Wochen",
streak_week: "Woche",
weekly_goal: "Wochenziel",
workouts_per_week_goal: "Trainings / Woche",
set_goal: "Ziel setzen",
goal_set: "Ziel gesetzt",
intervals: "Intervalle",
no_intervals: "Keine",
new_interval: "Neues Intervall",
edit_interval: "Intervall bearbeiten",
delete_interval: "Löschen",
delete_interval_confirm: "Diese Intervallvorlage löschen?",
add_step: "+ Schritt hinzufügen",
add_group: "+ Wiederholungsgruppe",
repeat_times: "mal",
ungroup: "Auflösen",
group_label: "Wiederholen",
step_label: "Bezeichnung",
meters: "Meter",
seconds: "Sekunden",
intervals_complete: "Intervalle abgeschlossen",
select_interval: "Intervall wählen",
custom: "Eigene",
steps_count: "Schritte",
save_interval: "Intervall speichern",
interval_name_placeholder: "Intervallname",
label_easy: "Leicht",
label_moderate: "Moderat",
label_hard: "Hart",
label_sprint: "Sprint",
label_recovery: "Erholung",
label_hill_sprints: "Bergsprints",
label_tempo: "Tempo",
label_warm_up: "Aufwärmen",
label_cool_down: "Abkühlen",
nutrition_title: "Ernährung",
breakfast: "Frühstück",
lunch: "Mittagessen",
dinner: "Abendessen",
snack: "Snack",
add_food: "Essen hinzufügen",
search_food: "Essen suchen…",
amount_grams: "Menge (g)",
meal_type: "Mahlzeit",
daily_goal: "Tagesziel",
calorie_target: "Kalorienziel (kcal)",
protein_goal: "Proteinziel",
protein_fixed: "Fest (g/Tag)",
protein_per_kg: "Pro kg Körpergewicht",
fat_percent: "Fett-Anteil",
carb_percent: "KH-Anteil",
kcal: "kcal",
protein: "Protein",
fat: "Fett",
carbs: "Kohlenhydrate",
remaining: "übrig",
over: "über",
no_entries_yet: "Noch keine Einträge. Füge Essen hinzu, um zu tracken.",
set_goal_prompt: "Setze ein Kalorienziel, um mit dem Tracking zu beginnen.",
micro_details: "Mikronährstoffe",
of_daily: "vom Tagesziel",
per_serving: "pro Portion",
log_food: "Eintragen",
delete_entry_confirm: "Diesen Eintrag löschen?",
period_tracker: "Periodentracker",
current_period: "Aktuelle Periode",
no_period_data: "Noch keine Periodendaten. Erfasse deine erste Periode.",
no_active_period: "Keine aktive Periode.",
start_period: "Periode starten",
end_period: "Periode vorbei",
period_day: "Tag",
predicted_end: "Voraussichtliches Ende",
next_period: "Nächste Periode",
cycle_length: "Zykluslänge",
period_length: "Periodenlänge",
avg_cycle: "Ø Zyklus",
avg_period: "Ø Periode",
days: "Tage",
delete_period_confirm: "Diesen Periodeneintrag löschen?",
add_past_period: "Vergangene Periode hinzufügen",
period_start: "Beginn",
period_end: "Ende",
ongoing: "laufend",
share: "Teilen",
shared_with: "Geteilt mit",
add_user: "Nutzer hinzufügen…",
no_shared: "Mit niemandem geteilt.",
shared_by: "Geteilt von",
fertile_window: "Fruchtbares Fenster",
peak_fertility: "Höchste Fruchtbarkeit",
ovulation: "Eisprung",
fertile: "Fruchtbar",
luteal_phase: "Luteal",
predicted_ovulation: "Voraussichtlicher Eisprung",
to: "bis",
overview: "Überblick",
tips: "Tipps",
similar_exercises: "Ähnliche Übungen",
primary_muscles: "Primär",
secondary_muscles: "Sekundär",
play_video: "Video abspielen",
nutrition_stats: "Ernährung",
protein_per_kg_unit: "g/kg",
calorie_balance: "Kalorienbilanz",
calorie_balance_unit: "kcal/Tag",
diet_adherence: "Einhaltung",
seven_day_avg: "7-Tage-Ø",
thirty_day: "30 Tage",
macro_split: "Makroverteilung",
no_nutrition_data: "Noch keine Ernährungsdaten. Beginne mit dem Tracking.",
target: "Ziel",
days_tracked: "Tage erfasst",
since_start: "Seit Beginn",
no_weight_data: "Gewicht eintragen",
no_calorie_goal: "Kalorienziel setzen",
muscle_balance: "Muskelbalance",
weekly_sets: "Sätze pro Woche",
custom_meals: "Eigene Mahlzeiten",
custom_meal: "Eigene Mahlzeit",
new_meal: "Neue Mahlzeit",
meal_name: "Name der Mahlzeit",
add_ingredient: "Zutat hinzufügen",
no_custom_meals: "Noch keine eigenen Mahlzeiten.",
create_meal_hint: "Erstelle wiederverwendbare Mahlzeiten zum schnellen Eintragen.",
ingredients: "Zutaten",
total: "Gesamt",
log_meal: "Mahlzeit eintragen",
delete_meal_confirm: "Diese Mahlzeit löschen?",
save_meal: "Mahlzeit speichern",
favorites: "Favoriten",
per_100g: "pro 100 g",
macros: "Makronährstoffe",
minerals: "Mineralstoffe",
vitamins: "Vitamine",
amino_acids: "Aminosäuren",
essential: "Essenziell",
non_essential: "Nicht-essenziell",
saturated_fat: "Gesättigte Fettsäuren",
fiber: "Ballaststoffe",
sugars: "Zucker",
source_db: "Quelle",
initializing_gps: "GPS wird initialisiert…",
// Check-in page
failed_to_load_more: "Laden fehlgeschlagen",
body_part_count_one: "1 Körperteil",
body_parts_count_other: "Körperteile",
updated_toast: "Aktualisiert",
body_fat_label: "Körperfett",
clear_action: "Leeren",
measure_short: "Messen",
edit_all_fields: "Alle Felder bearbeiten",
measurement_saved: "Messung gespeichert",
// Stats page
bf_delta_from_prefix: "Δ von",
set_height_birthyear_weight: "Größe, Geburtsjahr & Gewicht eintragen",
actual_label: "Ist",
target_label: "Ziel",
calorie_balance_tooltip: "Durchschnittlich gegessene Kalorien minus geschätzter Verbrauch (TDEE + erfasste Trainingskilokalorien) der letzten 7 Tage. Negativ = Defizit, positiv = Überschuss.",
daily_expenditure_estimate_prefix: "Geschätzter Tagesverbrauch:",
diet_adherence_tooltip: "Prozent der Tage, an denen die gegessenen Kalorien innerhalb von ±10 % deines Ziels lagen (bereinigt um verbrannte Trainings­kalorien). Nicht erfasste Tage zählen als verfehlt.",
// Misc page titles / labels
exercise_title: "Übung",
recent_label: "Aktuell",
starts_with: "beginnt mit",
days_ago_template: "vor {n} Tagen"
} as const;
+392
View File
@@ -0,0 +1,392 @@
/** Generated by scripts/split-fitness-i18n.ts. */
import type { de } from './de';
export const en = {
save: "Save",
saving: "Saving…",
cancel: "CANCEL",
delete_: "Delete",
edit: "Edit",
loading: "Loading…",
set: "set",
sets: "sets",
exercise: "exercise",
exercises_word: "exercises",
kg: "kg",
km: "km",
min: "min",
stats_title: "Stats",
workout_singular: "Workout",
workouts_plural: "Workouts",
lifted: "Lifted",
est_kcal: "Est. kcal",
burned: "Burned",
kcal_set_profile: "Set sex & height in",
covered: "Covered",
workouts_per_week: "Workouts per week",
sex: "Sex",
male: "Male",
female: "Female",
height: "Height (cm)",
birth_year: "Birth Year",
no_workout_data: "No workout data to display yet.",
weight: "Weight",
history_title: "History",
no_workouts_yet: "No workouts yet. Start your first workout!",
load_more: "Load more",
date: "Date",
time: "Time",
duration_min: "Duration (min)",
notes: "Notes",
notes_placeholder: "Workout notes...",
gps_track_stored: "GPS track stored",
add_set: "+ ADD SET",
add_exercise: "+ ADD EXERCISE",
splits: "Splits",
pace: "PACE",
upload_gpx: "Upload GPX",
uploading: "Uploading...",
download_gpx: "Download GPX",
elevation: "Elevation",
elevation_unit: "m",
elevation_gain: "Gain",
elevation_loss: "Loss",
cadence: "Cadence",
cadence_unit: "spm",
cadence_permission_missing: "Cadence disabled — grant Activity Recognition in system settings",
personal_records: "Personal Records",
delete_session_confirm: "Delete this workout session?",
remove_gps_confirm: "Remove GPS track from this exercise?",
recalc_title: "Recalculate volume, PRs, and GPS previews",
next_in_schedule: "Next in schedule",
start_empty_workout: "Empty Workout",
templates: "Templates",
schedule: "Schedule",
my_templates: "My Templates",
no_templates_yet: "No templates yet. Browse the library or create your own.",
template_library: "Template Library",
browse_library: "Browse Library",
template_added: "Template added",
edit_template: "Edit Template",
new_template: "New Template",
template_name_placeholder: "Template name",
add_set_lower: "+ Add set",
add_exercise_btn: "Add Exercise",
save_template: "Save Template",
workout_schedule: "Workout Schedule",
schedule_hint: "Select templates and arrange their order. After completing a workout, the next one in the rotation will be suggested.",
available_templates: "Available templates",
all_templates_scheduled: "All templates are in the schedule",
save_schedule: "Save Schedule",
start_workout: "Start Workout",
delete_template: "Delete",
workout_complete: "Workout Complete",
workout_saved_offline: "Saved offline — will sync when back online.",
duration: "Duration",
tonnage: "Tonnage",
distance: "Distance",
exercises_heading: "Exercises",
volume: "volume",
avg: "avg",
update_template: "Update Template",
template_updated: "Template updated",
template_diff_desc: "Your weights or reps differ from the template:",
updating: "Updating...",
view_workout: "VIEW WORKOUT",
done: "DONE",
workout_name_placeholder: "Workout name",
cancel_workout: "CANCEL WORKOUT",
finish: "FINISH",
new_set_added: "new set",
new_sets_added: "new sets",
exercises_title: "Exercises",
search_exercises: "Search exercises…",
no_exercises_match: "No exercises match your search.",
type_any: "Any type",
type_weights: "Strength",
type_stretches: "Stretches",
stretch_pill: "Stretch",
strength_pill: "Strength",
cardio_pill: "Cardio",
plyo_pill: "Plyo",
about: "ABOUT",
history_tab: "HISTORY",
charts: "CHARTS",
records: "RECORDS",
instructions: "Instructions",
no_history_yet: "No history for this exercise yet.",
est_1rm: "EST. 1RM",
best_set_1rm: "Best Set (Est. 1RM)",
best_set_max: "Best Set (Max Weight)",
total_volume: "Total Volume",
not_enough_data: "Not enough data to display charts yet.",
estimated_1rm: "Estimated 1RM",
max_volume: "Max Volume",
max_weight: "Max Weight",
rep_records: "Rep Records",
reps: "REPS",
best_performance: "BEST PERFORMANCE",
measure_title: "Measure",
profile: "Profile",
profile_setup_cta: "Add height & birth year to unlock BMI, TDEE and calorie balance stats.",
set_up_profile: "Set up",
edit_profile: "Edit profile",
dismiss: "Dismiss",
new_measurement: "New Measurement",
edit_measurement: "Edit Measurement",
weight_kg: "Weight (kg)",
body_fat: "Body Fat %",
calories_kcal: "Calories (kcal)",
body_parts_cm: "Body Parts (cm)",
neck: "Neck",
shoulders: "Shoulders",
chest: "Chest",
l_bicep: "L Bicep",
r_bicep: "R Bicep",
l_forearm: "L Forearm",
r_forearm: "R Forearm",
waist: "Waist",
hips: "Hips",
l_thigh: "L Thigh",
r_thigh: "R Thigh",
l_calf: "L Calf",
r_calf: "R Calf",
biceps: "Biceps",
forearms: "Forearms",
thighs: "Thighs",
calves: "Calves",
measure_tip_neck: "Just below the Adams apple, tape parallel to the floor.",
measure_tip_shoulders: "Widest point across the deltoids, arms relaxed at your sides.",
measure_tip_chest: "At nipple line after a normal exhale, tape horizontal.",
measure_tip_biceps: "Arm flexed at the peak; tape around the thickest part.",
measure_tip_forearms: "Widest point below the elbow, arm hanging relaxed.",
measure_tip_waist: "At the navel, relaxed — dont suck in.",
measure_tip_hips: "Around the widest point of the buttocks.",
measure_tip_thighs: "Midway between hip crease and knee.",
measure_tip_calves: "Widest point, standing with weight on both feet.",
save_measurement: "Save Measurement",
update_measurement: "Update Measurement",
measure_body_parts: "Measure body parts",
measure_body_parts_sub: "Guided tape-measure flow — one part at a time.",
last_measured: "Last measured",
no_measurements_yet: "No measurements yet",
step_n_of_m: "Step {n} of {m}",
over_time: "{label} over time",
first_measurement_hint: "First measurement — your entry will appear here.",
running_totals: "Running totals",
review_save: "Review & save",
ready_to_save: "Ready to save",
review_numbers: "Review your numbers below.",
skip: "Skip",
next: "Next",
back: "Back",
review: "Review",
edit_again: "Edit again",
exit: "Exit",
same_both_sides: "Same on both sides",
copy_l_to_r: "Copy L → R",
copy_l_to_r_before: "Copy L",
copy_l_to_r_after: "R",
kbd_nav: "nav",
kbd_next: "next",
kbd_skip: "skip",
kbd_wheel: "±0.1",
kbd_hint: "Press ? for shortcuts",
no_body_parts_selected: "Enter at least one value before saving.",
today_short: "today",
latest: "Latest",
body_fat_short: "Body Fat",
calories: "Calories",
body_parts: "Body Parts",
body_measurements_only: "Body measurements only",
delete_measurement_confirm: "Delete this measurement?",
general: "General",
body_fat_pct: "Body Fat (%)",
history: "History",
past_measurements: "Past measurements",
show_more: "Show more",
overwrite_title: "Overwrite existing values?",
overwrite_message: "You already have values for this date: {fields}. Replace them?",
overwrite_confirm: "Overwrite",
same_as_last: "Same as last",
set_header: "SET",
prev_header: "PREV",
rpe: "RPE",
picker_title: "Add Exercise",
no_exercises_found: "No exercises found",
last_performed: "Last performed:",
today: "Today",
yesterday: "Yesterday",
days_ago: "days ago",
more: "more",
active_workout: "Active Workout",
streak: "Streak",
streak_weeks: "Weeks",
streak_week: "Week",
weekly_goal: "Weekly Goal",
workouts_per_week_goal: "workouts / week",
set_goal: "Set Goal",
goal_set: "Goal set",
intervals: "Intervals",
no_intervals: "None",
new_interval: "New Interval",
edit_interval: "Edit Interval",
delete_interval: "Delete",
delete_interval_confirm: "Delete this interval template?",
add_step: "+ Add Step",
add_group: "+ Add Repeat Group",
repeat_times: "times",
ungroup: "Ungroup",
group_label: "Repeat",
step_label: "Label",
meters: "meters",
seconds: "seconds",
intervals_complete: "Intervals complete",
select_interval: "Select Interval",
custom: "Custom",
steps_count: "steps",
save_interval: "Save Interval",
interval_name_placeholder: "Interval name",
label_easy: "Easy",
label_moderate: "Moderate",
label_hard: "Hard",
label_sprint: "Sprint",
label_recovery: "Recovery",
label_hill_sprints: "Hill Sprints",
label_tempo: "Tempo",
label_warm_up: "Warm Up",
label_cool_down: "Cool Down",
nutrition_title: "Nutrition",
breakfast: "Breakfast",
lunch: "Lunch",
dinner: "Dinner",
snack: "Snack",
add_food: "Add food",
search_food: "Search food…",
amount_grams: "Amount (g)",
meal_type: "Meal",
daily_goal: "Daily Goal",
calorie_target: "Calorie target (kcal)",
protein_goal: "Protein goal",
protein_fixed: "Fixed (g/day)",
protein_per_kg: "Per kg bodyweight",
fat_percent: "Fat ratio",
carb_percent: "Carbs ratio",
kcal: "kcal",
protein: "Protein",
fat: "Fat",
carbs: "Carbs",
remaining: "left",
over: "over",
no_entries_yet: "No entries yet. Add food to start tracking.",
set_goal_prompt: "Set a daily calorie goal to start tracking.",
micro_details: "Micronutrients",
of_daily: "of daily goal",
per_serving: "per serving",
log_food: "Log",
delete_entry_confirm: "Delete this food entry?",
period_tracker: "Period Tracker",
current_period: "Current Period",
no_period_data: "No period data yet. Log your first period to start tracking.",
no_active_period: "No active period.",
start_period: "Start Period",
end_period: "Period Ended",
period_day: "Day",
predicted_end: "Predicted end",
next_period: "Next period",
cycle_length: "Cycle length",
period_length: "Period length",
avg_cycle: "Avg. cycle",
avg_period: "Avg. period",
days: "days",
delete_period_confirm: "Delete this period entry?",
add_past_period: "Add Past Period",
period_start: "Start",
period_end: "End",
ongoing: "ongoing",
share: "Share",
shared_with: "Shared with",
add_user: "Add user…",
no_shared: "Not shared with anyone.",
shared_by: "Shared by",
fertile_window: "Fertile window",
peak_fertility: "Peak fertility",
ovulation: "Ovulation",
fertile: "Fertile",
luteal_phase: "Luteal",
predicted_ovulation: "Predicted ovulation",
to: "to",
overview: "Overview",
tips: "Tips",
similar_exercises: "Similar Exercises",
primary_muscles: "Primary",
secondary_muscles: "Secondary",
play_video: "Play Video",
nutrition_stats: "Nutrition",
protein_per_kg_unit: "g/kg",
calorie_balance: "Calorie Balance",
calorie_balance_unit: "kcal/day",
diet_adherence: "Adherence",
seven_day_avg: "7-day avg",
thirty_day: "30 days",
macro_split: "Macro Split",
no_nutrition_data: "No nutrition data yet. Start logging food to see stats.",
target: "Target",
days_tracked: "days tracked",
since_start: "Since start",
no_weight_data: "Log weight to enable",
no_calorie_goal: "Set calorie goal",
muscle_balance: "Muscle Balance",
weekly_sets: "Sets per week",
custom_meals: "Custom Meals",
custom_meal: "Custom Meal",
new_meal: "New Meal",
meal_name: "Meal name",
add_ingredient: "Add ingredient",
no_custom_meals: "No custom meals yet.",
create_meal_hint: "Create reusable meals for quick logging.",
ingredients: "Ingredients",
total: "Total",
log_meal: "Log Meal",
delete_meal_confirm: "Delete this custom meal?",
save_meal: "Save Meal",
favorites: "Favorites",
per_100g: "per 100 g",
macros: "Macronutrients",
minerals: "Minerals",
vitamins: "Vitamins",
amino_acids: "Amino Acids",
essential: "Essential",
non_essential: "Non-Essential",
saturated_fat: "Saturated Fat",
fiber: "Fiber",
sugars: "Sugars",
source_db: "Source",
initializing_gps: "Initializing GPS…",
// Check-in page
failed_to_load_more: "Failed to load more",
body_part_count_one: "1 body part",
body_parts_count_other: "body parts",
updated_toast: "Updated",
body_fat_label: "Body Fat",
clear_action: "Clear",
measure_short: "Measure",
edit_all_fields: "Edit all fields",
measurement_saved: "Measurement saved",
// Stats page
bf_delta_from_prefix: "Δ from",
set_height_birthyear_weight: "Set height, birth year & weight",
actual_label: "Actual",
target_label: "Target",
calorie_balance_tooltip: "Average daily calories eaten minus estimated expenditure (TDEE + tracked workout calories) over the last 7 days. Negative = deficit, positive = surplus.",
daily_expenditure_estimate_prefix: "Est. daily expenditure:",
diet_adherence_tooltip: "Percentage of days where calories eaten were within ±10% of your goal (adjusted for exercise calories burned). Days without tracking count as misses.",
// Misc page titles / labels
exercise_title: "Exercise",
recent_label: "Recent",
starts_with: "starts with",
days_ago_template: "{n} days ago"
} as const satisfies Record<keyof typeof de, string>;
+218
View File
@@ -0,0 +1,218 @@
/** German recipes UI strings — source of truth for the key set. */
export const de = {
// Layout / nav
all_recipes: 'Alle Rezepte',
favorites: 'Favoriten',
season_nav: 'Saison',
category_nav: 'Kategorie',
icon_nav: 'Icon',
tags_nav: 'Tags',
// Nutrition summary
nutrition: 'Nährwerte',
per_portion: 'pro Portion',
protein: 'Eiweiß',
fat: 'Fett',
carbs: 'Kohlenh.',
fiber: 'Ballaststoffe',
sugars: 'Zucker',
saturated_fat: 'Ges. Fett',
details: 'Details',
vitamins: 'Vitamine',
minerals: 'Mineralstoffe',
coverage: 'Abdeckung',
not_tracked: 'Nicht erfasst',
amino_acids: 'Aminosäuren',
iron: 'Eisen',
zinc: 'Zink',
leucine: 'Leucin',
isoleucine: 'Isoleucin',
valine: 'Valin',
lysine: 'Lysin',
methionine: 'Methionin',
phenylalanine: 'Phenylalanin',
threonine: 'Threonin',
histidine: 'Histidin',
arginine: 'Arginin',
alanine: 'Alanin',
aspartic_acid: 'Asparaginsäure',
cysteine: 'Cystein',
glutamic_acid: 'Glutaminsäure',
glycine: 'Glycin',
proline: 'Prolin',
serine: 'Serin',
tyrosine: 'Tyrosin',
// Ingredients page
portions: 'Portionen:',
adjust_amount: 'Menge anpassen:',
ingredients: 'Zutaten',
cake_form: 'Backform',
adjust_cake_form: 'Backform anpassen',
round_form: 'Rund',
rectangular_form: 'Rechteckig',
diameter: 'Durchmesser',
outer_diameter: 'Aussen-Ø',
inner_diameter: 'Innen-Ø',
width: 'Breite',
length: 'Länge',
factor: 'Faktor',
restore_default: 'Standard wiederherstellen',
round_lowercase: 'rund',
// AddToFoodLogButton + meal labels
add_to_food_log: 'Zum Ernährungstagebuch',
added_to_food_log: 'Zum Ernährungstagebuch hinzugefügt',
add_failed: 'Fehler beim Hinzufügen',
portions_label: 'Portionen',
grams_label: 'Gramm',
meal_label: 'Mahlzeit',
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
log_action: 'Eintragen',
cancel: 'Abbrechen',
save: 'Speichern',
// To-try page
to_try_title: 'Zum Ausprobieren',
to_try_page_title: 'Zum Ausprobieren - Bocken Rezepte',
to_try_meta_description: 'Rezepte, die wir ausprobieren wollen.',
to_try_nothing: 'Noch nichts vorhanden',
to_try_empty_state: 'Füge ein Rezept hinzu, das du ausprobieren möchtest.',
recipe_name: 'Rezeptname',
label_optional: 'Bezeichnung (optional)',
notes_optional: 'Notizen (optional)',
add_link: 'Link hinzufügen',
add_recipe_to_try: 'Rezept hinzufügen',
edit_recipe: 'Rezept bearbeiten',
delete_recipe_confirm: 'Dieses Rezept löschen?',
// Search page
search_results_title: 'Suchergebnisse',
search_meta_description: 'Suchergebnisse in den Bockenschen Rezepten.',
filtered_by: 'Gefiltert nach:',
keywords_label: 'Stichwörter',
seasons_label: 'Monate',
favorites_only: 'Nur Favoriten',
search_error: 'Fehler bei der Suche:',
results_for: 'Ergebnisse für',
no_recipes_found: 'Keine Rezepte gefunden.',
try_other_search: 'Versuche es mit anderen Suchbegriffen.',
// Common page titles + shared
site_title: 'Bocken Rezepte',
all: 'Alle',
// Index page
index_title: 'Rezepte',
in_season_now: 'In Saison',
meta_alt_hero: 'Pasta al Ragu mit Linguine',
// Detail page
season_label: 'Saison:',
keywords_colon: 'Stichwörter:',
last_modified: 'Letzte Änderung:',
// Favorites
favorites_page_title: 'Meine Favoriten - Bocken Rezepte',
no_favorites_yet: 'Noch keine Favoriten gespeichert',
error_loading_favorites: 'Fehler beim Laden der Favoriten:',
recipe_singular_link: 'Rezept',
recipes_to_try_link: 'Zum Ausprobieren',
no_matching_favorites: 'Keine passenden Favoriten gefunden.',
// Error pages
recipe_not_found: 'Rezept nicht gefunden',
recipe_not_found_desc: 'Das angeforderte Rezept konnte nicht gefunden werden.',
checking_german_version: 'Suche nach deutscher Version…',
recipes_link: 'Rezepte',
// Categories / tags / season / icon / tips index pages
categories_title: 'Kategorien',
keywords_title: 'Stichwörter',
search_tags: 'Tags suchen…',
in_season_title: 'Saisonal',
icons_title: 'Icons',
tips_title: 'Tipps & Tricks',
favorites_meta_description: 'Meine favorisierten Rezepte aus der Bockenschen Küche.',
empty_favorites_1: 'Du hast noch keine Rezepte als Favoriten gespeichert.',
empty_favorites_2: 'Besuche ein Rezept und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.',
// Filters
filter_mode: 'Filter-Modus',
and_label: 'UND',
or_label: 'ODER',
select_category_placeholder: 'Kategorie auswählen…',
select_season_placeholder: 'Saison auswählen…',
// Search component
search_placeholder_short: 'Suche…',
search_title: 'Suchen',
clear_search_title: 'Sucheintrag löschen',
// Tag / Category landing
recipes_with_keyword: 'Rezepte mit Stichwort',
recipes_in_category: 'Rezepte in Kategorie',
// Card actions
edit: 'Bearbeiten',
delete: 'Löschen',
// Administration page
administration_title: 'Administration',
untranslated_recipes: 'Unübersetzte Rezepte',
alt_text_generator: 'Alt-Text Generator',
image_colors: 'Bildfarben',
nutrition_mappings: 'Nährwert-Zuordnungen',
// Recipe detail page (long site title variant)
site_title_long: "Bocken'sche Rezepte",
// InstructionsPage section labels
preparation_section: 'Vorbereitung:',
bulk_fermentation: 'Stockgare:',
final_proof: 'Stückgare:',
baking_section: 'Backen:',
cooking_section: 'Kochen:',
on_the_plate: 'Auf dem Teller:',
instructions_label: 'Zubereitung',
at_temp: 'bei',
// CreateStepList baking
not_set: 'Nicht gesetzt',
duration: 'Dauer',
temperature: 'Temperatur',
mode_label: 'Modus',
custom_mode_placeholder: 'oder eigenen Modus eingeben…',
// Administration page descriptions
administration_description: 'Rezepte und Inhalte verwalten',
untranslated_description: 'Rezepte ansehen und verwalten, die übersetzt werden müssen',
alt_text_description: 'Alternativtext für Rezeptbilder mit KI generieren',
image_colors_description: 'Dominante Farben aus Rezeptbildern für Ladeplatzhalter extrahieren',
nutrition_mappings_description: 'Kalorien- und Nährwertdaten für alle Rezepte generieren oder aktualisieren',
// Smaller filters / pages
loading_offline: 'Lade Offline-Inhalte…',
hide_filters: 'Filter ausblenden',
show_filters: 'Filter einblenden',
select_icon_placeholder: 'Icon auswählen…',
add_tag_placeholder: 'Tag eingeben oder auswählen…',
// Index / tips / yeast
recipes_growing_suffix: 'Rezepte und stetig wachsend…',
recipes_collection_meta: 'Eine stetig wachsende Ansammlung an Rezepten aus der Bockenschen Küche.',
tips_description: "Eine stetig wachsende Ansammlung an Rezepten aus der Bockenschen Küche.",
yeast_toggle_title: 'Zwischen Frischhefe und Trockenhefe wechseln',
// Search results pageTitle
search_results_for_word: 'für',
// Favorites count label
favorites_count_label: 'favorisierte Rezepte',
favorite_recipe_singular: 'favorite recipe',
favorite_recipes_plural: 'favorite recipes'
} as const;
+218
View File
@@ -0,0 +1,218 @@
import type { de } from './de';
export const en = {
// Layout / nav
all_recipes: 'All Recipes',
favorites: 'Favorites',
season_nav: 'Season',
category_nav: 'Category',
icon_nav: 'Icon',
tags_nav: 'Tags',
// Nutrition summary
nutrition: 'Nutrition',
per_portion: 'per portion',
protein: 'Protein',
fat: 'Fat',
carbs: 'Carbs',
fiber: 'Fiber',
sugars: 'Sugars',
saturated_fat: 'Sat. Fat',
details: 'Details',
vitamins: 'Vitamins',
minerals: 'Minerals',
coverage: 'coverage',
not_tracked: 'Not tracked',
amino_acids: 'Amino Acids',
iron: 'Iron',
zinc: 'Zinc',
leucine: 'Leucine',
isoleucine: 'Isoleucine',
valine: 'Valine',
lysine: 'Lysine',
methionine: 'Methionine',
phenylalanine: 'Phenylalanine',
threonine: 'Threonine',
histidine: 'Histidine',
arginine: 'Arginine',
alanine: 'Alanine',
aspartic_acid: 'Aspartic Acid',
cysteine: 'Cysteine',
glutamic_acid: 'Glutamic Acid',
glycine: 'Glycine',
proline: 'Proline',
serine: 'Serine',
tyrosine: 'Tyrosine',
// Ingredients page
portions: 'Portions:',
adjust_amount: 'Adjust Amount:',
ingredients: 'Ingredients',
cake_form: 'Cake form',
adjust_cake_form: 'Adjust cake form',
round_form: 'Round',
rectangular_form: 'Rectangular',
diameter: 'Diameter',
outer_diameter: 'Outer Ø',
inner_diameter: 'Inner Ø',
width: 'Width',
length: 'Length',
factor: 'Factor',
restore_default: 'Restore default',
round_lowercase: 'round',
// AddToFoodLogButton + meal labels
add_to_food_log: 'Add to food log',
added_to_food_log: 'Added to food log',
add_failed: 'Failed to add',
portions_label: 'Portions',
grams_label: 'Grams',
meal_label: 'Meal',
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
log_action: 'Log',
cancel: 'Cancel',
save: 'Save',
// To-try page
to_try_title: 'To Try',
to_try_page_title: 'Recipes To Try - Bocken Recipes',
to_try_meta_description: 'Recipes we want to try from around the web.',
to_try_nothing: 'Nothing here yet',
to_try_empty_state: 'Add a recipe you want to try using the form below.',
recipe_name: 'Recipe name',
label_optional: 'Label (optional)',
notes_optional: 'Notes (optional)',
add_link: 'Add link',
add_recipe_to_try: 'Add recipe to try',
edit_recipe: 'Edit recipe',
delete_recipe_confirm: 'Delete this recipe?',
// Search page
search_results_title: 'Search Results',
search_meta_description: "Search results in Bocken's recipes.",
filtered_by: 'Filtered by:',
keywords_label: 'Keywords',
seasons_label: 'Seasons',
favorites_only: 'Favorites only',
search_error: 'Search error:',
results_for: 'results for',
no_recipes_found: 'No recipes found.',
try_other_search: 'Try different search terms.',
// Common page titles + shared
site_title: 'Bocken Recipes',
all: 'All',
// Index page
index_title: 'Recipes',
in_season_now: 'In Season',
meta_alt_hero: 'Pasta al Ragu with Linguine',
// Detail page
season_label: 'Season:',
keywords_colon: 'Keywords:',
last_modified: 'Last modified:',
// Favorites
favorites_page_title: 'My Favorites - Bocken Recipes',
no_favorites_yet: 'No favorites saved yet',
error_loading_favorites: 'Error loading favorites:',
recipe_singular_link: 'recipe',
recipes_to_try_link: 'Recipes to try',
no_matching_favorites: 'No matching favorites found.',
// Error pages
recipe_not_found: 'Recipe Not Found',
recipe_not_found_desc: 'The requested recipe could not be found.',
checking_german_version: 'Checking for German version…',
recipes_link: 'Recipes',
// Categories / tags / season / icon / tips index pages
categories_title: 'Categories',
keywords_title: 'Keywords',
search_tags: 'Search tags…',
in_season_title: 'In Season',
icons_title: 'Icons',
tips_title: 'Tips & Tricks',
favorites_meta_description: "My favorite recipes from Bocken's kitchen.",
empty_favorites_1: "You haven't saved any recipes as favorites yet.",
empty_favorites_2: 'Visit a recipe and click the heart icon to add it to your favorites.',
// Filters
filter_mode: 'Filter Mode',
and_label: 'AND',
or_label: 'OR',
select_category_placeholder: 'Select category…',
select_season_placeholder: 'Select season…',
// Search component
search_placeholder_short: 'Search…',
search_title: 'Search',
clear_search_title: 'Clear search',
// Tag / Category landing
recipes_with_keyword: 'Recipes with Keyword',
recipes_in_category: 'Recipes in Category',
// Card actions
edit: 'Edit',
delete: 'Delete',
// Administration page
administration_title: 'Administration',
untranslated_recipes: 'Untranslated Recipes',
alt_text_generator: 'Alt-Text Generator',
image_colors: 'Image Colors',
nutrition_mappings: 'Nutrition Mappings',
// Recipe detail page
site_title_long: 'Bocken Recipes',
// InstructionsPage section labels
preparation_section: 'Preparation:',
bulk_fermentation: 'Bulk Fermentation:',
final_proof: 'Final Proof:',
baking_section: 'Baking:',
cooking_section: 'Cooking:',
on_the_plate: 'On the Plate:',
instructions_label: 'Instructions',
at_temp: 'at',
// CreateStepList baking
not_set: 'Not set',
duration: 'Duration',
temperature: 'Temperature',
mode_label: 'Mode',
custom_mode_placeholder: 'or enter custom mode…',
// Administration page descriptions
administration_description: 'Manage recipes and content',
untranslated_description: 'View and manage recipes that need translation',
alt_text_description: 'Generate alternative text for recipe images using AI',
image_colors_description: 'Extract dominant colors from recipe images for loading placeholders',
nutrition_mappings_description: 'Generate or regenerate calorie and nutrition data for all recipes',
// Smaller filters / pages
loading_offline: 'Loading offline content…',
hide_filters: 'Hide Filters',
show_filters: 'Show Filters',
select_icon_placeholder: 'Select icon…',
add_tag_placeholder: 'Type or select tag…',
// Index / tips / yeast
recipes_growing_suffix: 'recipes and constantly growing…',
recipes_collection_meta: "A constantly growing collection of recipes from Bocken's kitchen.",
tips_description: "A constantly growing collection of recipes from Bocken's kitchen.",
yeast_toggle_title: 'Switch between fresh yeast and dry yeast',
// Search results pageTitle
search_results_for_word: 'for',
// Favorites count label
favorites_count_label: 'favorite recipes',
favorite_recipe_singular: 'favorite recipe',
favorite_recipes_plural: 'favorite recipes'
} as const satisfies Record<keyof typeof de, string>;
+19
View File
@@ -0,0 +1,19 @@
/**
* Shared/top-level UI strings used across the site (homepage, auth header,
* offline sync button, date picker, error view).
*
* Per-locale tables live in `$lib/i18n/common/{de,en}.ts`. Use
* `m[lang].key` (or `m[lang][expr]` for dynamic keys) directly:
*
* import { m } from '$lib/js/commonI18n';
* const t = $derived(m[lang]);
* ... t.login ...
*/
import { de } from '$lib/i18n/common/de';
import { en } from '$lib/i18n/common/en';
export const m = { de, en } as const;
export type CommonLang = keyof typeof m;
export type CommonKey = keyof typeof de;
+26 -293
View File
@@ -1,24 +1,24 @@
/** Cospend route i18n — slug mappings and UI translations */
/** Detect language from a cospend path by checking the root segment */
export function detectCospendLang(pathname: string): 'en' | 'de' {
export function detectCospendLang(pathname: string): CospendLang {
const first = pathname.split('/').filter(Boolean)[0];
return first === 'expenses' ? 'en' : 'de';
}
/** Convert a cospend path to the target language */
export function convertCospendPath(pathname: string, targetLang: 'en' | 'de'): string {
export function convertCospendPath(pathname: string, targetLang: CospendLang): string {
const targetRoot = targetLang === 'en' ? 'expenses' : 'cospend';
return pathname.replace(/^\/(cospend|expenses)/, `/${targetRoot}`);
}
/** Get the root slug for a given language */
export function cospendRoot(lang: 'en' | 'de'): string {
export function cospendRoot(lang: CospendLang): string {
return lang === 'en' ? 'expenses' : 'cospend';
}
/** Get translated nav labels */
export function cospendLabels(lang: 'en' | 'de') {
export function cospendLabels(lang: CospendLang) {
return {
dash: lang === 'en' ? 'Dashboard' : 'Dashboard',
list: lang === 'en' ? 'List' : 'Liste',
@@ -27,287 +27,16 @@ export function cospendLabels(lang: 'en' | 'de') {
};
}
type Translations = Record<string, Record<string, string>>;
const translations: Translations = {
// Page titles
cospend_title: { en: 'Expenses - Expense Sharing', de: 'Cospend - Ausgabenteilung' },
all_payments_title: { en: 'All Payments', de: 'Alle Zahlungen' },
settle_title: { en: 'Settle Debts', de: 'Schulden begleichen' },
recurring_title: { en: 'Recurring Payments', de: 'Wiederkehrende Zahlungen' },
shopping_list_title: { en: 'Shopping List', de: 'Einkaufsliste' },
payment_details: { en: 'Payment Details', de: 'Zahlungsdetails' },
import { de } from '$lib/i18n/cospend/de';
import { en } from '$lib/i18n/cospend/en';
// Dashboard
cospend: { en: 'Expenses', de: 'Cospend' },
settle_debts: { en: 'Settle Debts', de: 'Schulden begleichen' },
monthly_expenses_chart: { en: 'Monthly Expenses by Category', de: 'Monatliche Ausgaben nach Kategorie' },
loading_monthly: { en: 'Loading monthly expenses chart...', de: 'Monatliche Ausgaben werden geladen...' },
loading_recent: { en: 'Loading recent activity...', de: 'Letzte Aktivitäten werden geladen...' },
recent_activity: { en: 'Recent Activity', de: 'Letzte Aktivität' },
clear_filter: { en: 'Clear filter', de: 'Filter löschen' },
no_recent_in: { en: 'No recent activity in', de: 'Keine Aktivität in' },
paid_by: { en: 'Paid by', de: 'Bezahlt von' },
payment: { en: 'Payment', de: 'Zahlung' },
/** All cospend translations, keyed by locale. */
export const m = { de, en } as const;
// All Payments page
loading_payments: { en: 'Loading payments...', de: 'Zahlungen werden geladen...' },
no_payments_yet: { en: 'No payments yet', de: 'Noch keine Zahlungen' },
start_first_expense: { en: 'Start by adding your first shared expense', de: 'Füge deine erste geteilte Ausgabe hinzu' },
add_first_payment: { en: 'Add Your First Payment', de: 'Erste Zahlung hinzufügen' },
settlement: { en: 'Settlement', de: 'Ausgleich' },
split_details: { en: 'Split Details', de: 'Aufteilung' },
owes: { en: 'owes', de: 'schuldet' },
owed: { en: 'owed', de: 'bekommt' },
even: { en: 'even', de: 'ausgeglichen' },
previous: { en: '← Previous', de: '← Zurück' },
next: { en: 'Next →', de: 'Weiter →' },
load_more: { en: 'Load More', de: 'Mehr laden' },
loading_ellipsis: { en: 'Loading...', de: 'Laden...' },
delete_payment_confirm: { en: 'Are you sure you want to delete this payment?', de: 'Diese Zahlung wirklich löschen?' },
export type CospendLang = keyof typeof m;
export type CospendKey = keyof typeof de;
// Payment detail labels
date: { en: 'Date:', de: 'Datum:' },
paid_by_label: { en: 'Paid by:', de: 'Bezahlt von:' },
created_by: { en: 'Created by:', de: 'Erstellt von:' },
category_label: { en: 'Category:', de: 'Kategorie:' },
split_method_label: { en: 'Split method:', de: 'Aufteilungsart:' },
description: { en: 'Description', de: 'Beschreibung' },
exchange_rate: { en: 'Exchange rate', de: 'Wechselkurs' },
receipt: { en: 'Receipt', de: 'Beleg' },
receipt_image: { en: 'Receipt Image', de: 'Belegbild' },
remove_image: { en: 'Remove Image', de: 'Bild entfernen' },
replace_image: { en: 'Replace Image', de: 'Bild ersetzen' },
upload_receipt: { en: 'Upload Receipt Image', de: 'Beleg hochladen' },
uploading_image: { en: 'Uploading image...', de: 'Bild wird hochgeladen...' },
file_too_large: { en: 'File size must be less than 5MB', de: 'Dateigrösse muss unter 5MB sein' },
invalid_image: { en: 'Please select a valid image file (JPEG, PNG, WebP)', de: 'Bitte eine gültige Bilddatei wählen (JPEG, PNG, WebP)' },
you: { en: 'You', de: 'Du' },
close: { en: 'Close', de: 'Schliessen' },
// Split descriptions
no_splits: { en: 'No splits', de: 'Keine Aufteilung' },
split_equal: { en: 'Split equally among', de: 'Gleichmässig aufgeteilt auf' },
paid_full_by: { en: 'Paid in full by', de: 'Vollständig bezahlt von' },
personal_equal: { en: 'Personal amounts + equal split among', de: 'Persönliche Beträge + Gleichverteilung auf' },
custom_split: { en: 'Custom split among', de: 'Individuelle Aufteilung auf' },
people: { en: 'people', de: 'Personen' },
// Settle page
settle_subtitle: { en: 'Record payments to settle outstanding debts between users', de: 'Zahlungen erfassen, um offene Schulden auszugleichen' },
loading_debts: { en: 'Loading debt information...', de: 'Schuldeninformationen werden geladen...' },
all_settled: { en: 'All Settled!', de: 'Alles beglichen!' },
no_debts_msg: { en: 'No outstanding debts to settle. Everyone is even!', de: 'Keine offenen Schulden. Alle sind ausgeglichen!' },
back_to_dashboard: { en: 'Back to Dashboard', de: 'Zurück zum Dashboard' },
available_settlements: { en: 'Available Settlements', de: 'Mögliche Ausgleiche' },
money_owed_to_you: { en: "Money You're Owed", de: 'Geld, das du bekommst' },
owes_you: { en: 'owes you', de: 'schuldet dir' },
receive_payment: { en: 'Receive Payment', de: 'Zahlung empfangen' },
money_you_owe: { en: 'Money You Owe', de: 'Geld, das du schuldest' },
you_owe: { en: 'you owe', de: 'du schuldest' },
make_payment: { en: 'Make Payment', de: 'Zahlung leisten' },
settlement_details: { en: 'Settlement Details', de: 'Ausgleichsdetails' },
settlement_amount: { en: 'Settlement Amount', de: 'Ausgleichsbetrag' },
record_settlement: { en: 'Record Settlement', de: 'Ausgleich erfassen' },
recording_settlement: { en: 'Recording Settlement...', de: 'Ausgleich wird erfasst...' },
cancel: { en: 'Cancel', de: 'Abbrechen' },
settlement_type: { en: 'Settlement Type', de: 'Ausgleichsart' },
select_settlement: { en: 'Select settlement type', de: 'Ausgleichsart wählen' },
receive_from: { en: 'Receive', de: 'Empfangen' },
from: { en: 'from', de: 'von' },
pay_to: { en: 'Pay', de: 'Zahlen' },
to: { en: 'to', de: 'an' },
from_user: { en: 'From User', de: 'Von Benutzer' },
select_payer: { en: 'Select payer', de: 'Zahler wählen' },
to_user: { en: 'To User', de: 'An Benutzer' },
select_recipient: { en: 'Select recipient', de: 'Empfänger wählen' },
settlement_amount_chf: { en: 'Settlement Amount (CHF)', de: 'Ausgleichsbetrag (CHF)' },
error_select_settlement: { en: 'Please select a settlement and enter an amount', de: 'Bitte einen Ausgleich wählen und Betrag eingeben' },
error_valid_amount: { en: 'Please enter a valid positive amount', de: 'Bitte einen gültigen positiven Betrag eingeben' },
settlement_payment: { en: 'Settlement Payment', de: 'Ausgleichszahlung' },
// Recurring page
recurring_subtitle: { en: 'Automate your regular shared expenses', de: 'Automatisiere deine regelmässigen geteilten Ausgaben' },
show_active_only: { en: 'Show active only', de: 'Nur aktive anzeigen' },
loading_recurring: { en: 'Loading recurring payments...', de: 'Wiederkehrende Zahlungen werden geladen...' },
no_recurring: { en: 'No recurring payments found', de: 'Keine wiederkehrenden Zahlungen gefunden' },
no_recurring_desc: { en: 'Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.', de: 'Erstelle deine erste wiederkehrende Zahlung für regelmässige Ausgaben wie Miete, Nebenkosten oder Abos.' },
active: { en: 'Active', de: 'Aktiv' },
inactive: { en: 'Inactive', de: 'Inaktiv' },
frequency: { en: 'Frequency:', de: 'Häufigkeit:' },
next_execution: { en: 'Next execution:', de: 'Nächste Ausführung:' },
last_executed: { en: 'Last executed:', de: 'Zuletzt ausgeführt:' },
ends: { en: 'Ends:', de: 'Endet:' },
split_between: { en: 'Split between:', de: 'Aufgeteilt zwischen:' },
gets: { en: 'gets', de: 'bekommt' },
edit: { en: 'Edit', de: 'Bearbeiten' },
pause: { en: 'Pause', de: 'Pausieren' },
activate: { en: 'Activate', de: 'Aktivieren' },
delete_: { en: 'Delete', de: 'Löschen' },
delete_recurring_confirm: { en: 'Are you sure you want to delete the recurring payment', de: 'Wiederkehrende Zahlung wirklich löschen' },
// Shopping list
items_done: { en: 'done', de: 'erledigt' },
add_item_placeholder: { en: 'Add item...', de: 'Artikel hinzufügen...' },
empty_list: { en: 'The shopping list is empty', de: 'Die Einkaufsliste ist leer' },
clear_checked: { en: 'Remove checked', de: 'Erledigte entfernen' },
share: { en: 'Share', de: 'Teilen' },
// Share modal
shared_links: { en: 'Shared Links', de: 'Geteilte Links' },
share_desc: { en: 'Anyone with an active link can edit the shopping list.', de: 'Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.' },
loading: { en: 'Loading...', de: 'Laden...' },
no_active_links: { en: 'No active links.', de: 'Keine aktiven Links.' },
remaining: { en: 'remaining', de: 'noch' },
change: { en: 'Change', de: 'Ändern' },
copy_link: { en: 'Copy link', de: 'Link kopieren' },
create_new_link: { en: 'Create new link', de: 'Neuen Link erstellen' },
copied: { en: 'Copied', de: 'Kopiert' },
expired: { en: 'expired', de: 'abgelaufen' },
// TTL
ttl_1h: { en: '1 hour', de: '1 Stunde' },
ttl_6h: { en: '6 hours', de: '6 Stunden' },
ttl_24h: { en: '24 hours', de: '24 Stunden' },
ttl_3d: { en: '3 days', de: '3 Tage' },
ttl_7d: { en: '7 days', de: '7 Tage' },
// Edit modal
kategorie: { en: 'Category', de: 'Kategorie' },
icon: { en: 'Icon', de: 'Icon' },
search_icon: { en: 'Search icon...', de: 'Icon suchen...' },
save: { en: 'Save', de: 'Speichern' },
saving: { en: 'Saving...', de: 'Speichern...' },
edit_name: { en: 'Name', de: 'Name' },
edit_qty: { en: 'Amount', de: 'Menge' },
edit_qty_ph: { en: 'e.g. 3x, 500g, 1L', de: 'z.B. 3x, 500g, 1L' },
// EnhancedBalance
your_balance: { en: 'Your Balance', de: 'Dein Saldo' },
you_are_owed: { en: 'You are owed', de: 'Du bekommst' },
you_owe_balance: { en: 'You owe', de: 'Du schuldest' },
all_even: { en: "You're all even", de: 'Alles ausgeglichen' },
owes_you_balance: { en: 'owes you', de: 'schuldet dir' },
you_owe_user: { en: 'you owe', de: 'du schuldest' },
transaction: { en: 'transaction', de: 'Transaktion' },
transactions: { en: 'transactions', de: 'Transaktionen' },
// DebtBreakdown
debt_overview: { en: 'Debt Overview', de: 'Schuldenübersicht' },
loading_debt_breakdown: { en: 'Loading debt breakdown...', de: 'Schuldenübersicht wird geladen...' },
who_owes_you: { en: 'Who owes you', de: 'Wer dir schuldet' },
you_owe_section: { en: 'You owe', de: 'Du schuldest' },
total: { en: 'Total', de: 'Gesamt' },
// Frequency descriptions (recurring payments)
freq_every_day: { en: 'Every day', de: 'Jeden Tag' },
freq_every_week: { en: 'Every week', de: 'Jede Woche' },
freq_every_month: { en: 'Every month', de: 'Jeden Monat' },
freq_custom: { en: 'Custom', de: 'Benutzerdefiniert' },
freq_unknown: { en: 'Unknown frequency', de: 'Unbekannte Häufigkeit' },
// Next execution
today_at: { en: 'Today at', de: 'Heute um' },
tomorrow_at: { en: 'Tomorrow at', de: 'Morgen um' },
in_days_at: { en: 'In {days} days at', de: 'In {days} Tagen um' },
// UsersList
split_between_users: { en: 'Split Between Users', de: 'Aufteilen zwischen' },
predefined_note: { en: 'Splitting between predefined users:', de: 'Aufteilung zwischen vordefinierten Benutzern:' },
remove: { en: 'Remove', de: 'Entfernen' },
add_user_placeholder: { en: 'Add user...', de: 'Benutzer hinzufügen...' },
add_user: { en: 'Add User', de: 'Benutzer hinzufügen' },
// SplitMethodSelector
split_method: { en: 'Split Method', de: 'Aufteilungsmethode' },
how_split: { en: 'How should this payment be split?', de: 'Wie soll diese Zahlung aufgeteilt werden?' },
split_5050: { en: 'Split 50/50', de: '50/50 teilen' },
custom_split_amounts: { en: 'Custom Split Amounts', de: 'Individuelle Beträge' },
personal_exceeds_total: { en: 'Warning: Personal amounts exceed total payment amount!', de: 'Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!' },
is_owed: { en: 'is owed', de: 'bekommt' },
error_prefix: { en: 'Error', de: 'Fehler' },
// Payment categories (for expense categories, not shopping)
cat_groceries: { en: 'Groceries', de: 'Lebensmittel' },
cat_shopping: { en: 'Shopping', de: 'Einkauf' },
cat_travel: { en: 'Travel', de: 'Reise' },
cat_restaurant: { en: 'Restaurant', de: 'Restaurant' },
cat_utilities: { en: 'Utilities', de: 'Nebenkosten' },
cat_fun: { en: 'Fun', de: 'Freizeit' },
cat_settlement: { en: 'Settlement', de: 'Ausgleich' },
// Payment add/edit forms
add_payment_title: { en: 'Add New Payment', de: 'Neue Zahlung' },
add_payment_subtitle: { en: 'Create a new shared expense or recurring payment', de: 'Neue geteilte Ausgabe oder wiederkehrende Zahlung erstellen' },
edit_payment_title: { en: 'Edit Payment', de: 'Zahlung bearbeiten' },
edit_payment_subtitle: { en: 'Modify payment details and receipt image', de: 'Zahlungsdetails und Beleg bearbeiten' },
edit_recurring_title: { en: 'Edit Recurring Payment', de: 'Wiederkehrende Zahlung bearbeiten' },
payment_details_section: { en: 'Payment Details', de: 'Zahlungsdetails' },
title_label: { en: 'Title *', de: 'Titel *' },
title_placeholder: { en: 'e.g., Dinner at restaurant', de: 'z.B. Abendessen im Restaurant' },
description_label: { en: 'Description', de: 'Beschreibung' },
description_placeholder: { en: 'Additional details...', de: 'Weitere Details...' },
category_star: { en: 'Category *', de: 'Kategorie *' },
amount_label: { en: 'Amount *', de: 'Betrag *' },
payment_date: { en: 'Payment Date', de: 'Zahlungsdatum' },
paid_by_form: { en: 'Paid by', de: 'Bezahlt von' },
make_recurring: { en: 'Make this a recurring payment', de: 'Als wiederkehrende Zahlung einrichten' },
recurring_section: { en: 'Recurring Payment', de: 'Wiederkehrende Zahlung' },
recurring_schedule: { en: 'Recurring Schedule', de: 'Wiederkehrender Zeitplan' },
frequency_label: { en: 'Frequency *', de: 'Häufigkeit *' },
freq_daily: { en: 'Daily', de: 'Täglich' },
freq_weekly: { en: 'Weekly', de: 'Wöchentlich' },
freq_monthly: { en: 'Monthly', de: 'Monatlich' },
freq_quarterly: { en: 'Quarterly', de: 'Vierteljährlich' },
freq_yearly: { en: 'Yearly', de: 'Jährlich' },
start_date: { en: 'Start Date *', de: 'Startdatum *' },
end_date_optional: { en: 'End Date (optional)', de: 'Enddatum (optional)' },
end_date_hint: { en: 'Leave empty for indefinite recurring', de: 'Leer lassen für unbefristete Wiederholung' },
next_execution_preview: { en: 'Next Execution', de: 'Nächste Ausführung' },
status_label: { en: 'Status', de: 'Status' },
create_payment: { en: 'Create payment', de: 'Zahlung erstellen' },
save_changes: { en: 'Save changes', de: 'Änderungen speichern' },
delete_payment: { en: 'Delete Payment', de: 'Zahlung löschen' },
deleting: { en: 'Deleting...', de: 'Löschen...' },
// Split configuration (edit page)
split_config: { en: 'Split Configuration', de: 'Aufteilungskonfiguration' },
split_method_form: { en: 'Split Method:', de: 'Aufteilungsart:' },
equal_split: { en: 'Equal Split', de: 'Gleichmässige Aufteilung' },
personal_equal_split: { en: 'Personal + Equal Split', de: 'Persönliche Beträge + Gleichverteilung' },
custom_proportions: { en: 'Custom Proportions', de: 'Individuelle Anteile' },
personal_amounts: { en: 'Personal Amounts', de: 'Persönliche Beträge' },
personal_amounts_desc: { en: 'Enter personal amounts for each user. The remainder will be split equally.', de: 'Persönliche Beträge für jeden Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.' },
total_personal: { en: 'Total Personal', de: 'Persönliche Summe' },
remainder_to_split: { en: 'Remainder to Split', de: 'Rest zum Aufteilen' },
personal_exceeds: { en: 'Personal amounts exceed total payment amount!', de: 'Persönliche Beträge übersteigen den Gesamtbetrag!' },
split_preview: { en: 'Split Preview', de: 'Aufteilungsvorschau' },
// Currency conversion
conversion_hint: { en: 'Amount will be converted to CHF using exchange rates for the payment date', de: 'Betrag wird anhand des Wechselkurses am Zahlungstag in CHF umgerechnet' },
fetching_rate: { en: 'Fetching exchange rate...', de: 'Wechselkurs wird abgerufen...' },
exchange_rate_date: { en: 'Exchange rate will be fetched for this date', de: 'Wechselkurs wird für dieses Datum abgerufen' },
// SplitMethodSelector
paid_in_full: { en: 'Paid in Full', de: 'Vollständig bezahlt' },
paid_in_full_for: { en: 'Paid in Full for', de: 'Vollständig bezahlt für' },
paid_in_full_by_you: { en: 'Paid in Full by You', de: 'Vollständig von dir bezahlt' },
paid_in_full_by: { en: 'Paid in Full by', de: 'Vollständig bezahlt von' },
// Shopping category names (for EN display)
cat_fruits_veg: { en: 'Fruits & Vegetables', de: 'Obst & Gemüse' },
cat_meat_fish: { en: 'Meat & Fish', de: 'Fleisch & Fisch' },
cat_dairy: { en: 'Dairy', de: 'Milchprodukte' },
cat_bakery: { en: 'Bread & Bakery', de: 'Brot & Backwaren' },
cat_grains: { en: 'Pasta, Rice & Grains', de: 'Pasta, Reis & Getreide' },
cat_spices: { en: 'Spices & Sauces', de: 'Gewürze & Saucen' },
cat_drinks: { en: 'Beverages', de: 'Getränke' },
cat_sweets: { en: 'Sweets & Snacks', de: 'Süßes & Snacks' },
cat_frozen: { en: 'Frozen', de: 'Tiefkühl' },
cat_household: { en: 'Household', de: 'Haushalt' },
cat_hygiene: { en: 'Hygiene & Body Care', de: 'Hygiene & Körperpflege' },
cat_other: { en: 'Other', de: 'Sonstiges' },
};
/** Category name translation map (German key → display name per language) */
const categoryDisplayNames: Record<string, Record<string, string>> = {
@@ -326,7 +55,7 @@ const categoryDisplayNames: Record<string, Record<string, string>> = {
};
/** Get translated category display name (shopping categories) */
export function categoryName(category: string, lang: 'en' | 'de'): string {
export function categoryName(category: string, lang: CospendLang): string {
return categoryDisplayNames[category]?.[lang] ?? category;
}
@@ -342,12 +71,12 @@ const paymentCategoryNames: Record<string, Record<string, string>> = {
};
/** Get translated payment category name */
export function paymentCategoryName(category: string, lang: 'en' | 'de'): string {
export function paymentCategoryName(category: string, lang: CospendLang): string {
return paymentCategoryNames[category]?.[lang] ?? category;
}
/** Get category options with translated labels */
export function getCategoryOptionsI18n(lang: 'en' | 'de') {
export function getCategoryOptionsI18n(lang: CospendLang) {
const emojis: Record<string, string> = {
groceries: '🛒', shopping: '🛍️', travel: '🚆',
restaurant: '🍽️', utilities: '⚡', fun: '🎉', settlement: '🤝'
@@ -360,13 +89,17 @@ export function getCategoryOptionsI18n(lang: 'en' | 'de') {
}));
}
/** Get a translated string */
export function t(key: string, lang: 'en' | 'de'): string {
return translations[key]?.[lang] ?? translations[key]?.en ?? key;
/**
* Get a translated string. Prefer `m[lang].key` directly in new code this
* helper is kept for the existing call sites and falls back to English then
* the key itself if the lookup misses.
*/
export function t(key: CospendKey, lang: CospendLang): string {
return m[lang][key] ?? m.en[key] ?? key;
}
/** Format TTL remaining time in the target language */
export function formatTTL(expiresAt: string, lang: 'en' | 'de'): string {
export function formatTTL(expiresAt: string, lang: CospendLang): string {
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return t('expired', lang);
const mins = Math.round(diff / 60000);
@@ -378,7 +111,7 @@ export function formatTTL(expiresAt: string, lang: 'en' | 'de'): string {
}
/** Get TTL options for the given language */
export function ttlOptions(lang: 'en' | 'de') {
export function ttlOptions(lang: CospendLang) {
return [
{ label: t('ttl_1h', lang), ms: 1 * 60 * 60 * 1000 },
{ label: t('ttl_6h', lang), ms: 6 * 60 * 60 * 1000 },
@@ -389,12 +122,12 @@ export function ttlOptions(lang: 'en' | 'de') {
}
/** Get locale string for number/date formatting */
export function locale(lang: 'en' | 'de'): string {
export function locale(lang: CospendLang): string {
return lang === 'en' ? 'en-CH' : 'de-CH';
}
/** Build a split description string */
export function splitDescription(payment: { splits?: any[]; splitMethod?: string; paidBy?: string }, lang: 'en' | 'de'): string {
export function splitDescription(payment: { splits?: any[]; splitMethod?: string; paidBy?: string }, lang: CospendLang): string {
if (!payment.splits || payment.splits.length === 0) return t('no_splits', lang);
const count = payment.splits.length;
@@ -410,7 +143,7 @@ export function splitDescription(payment: { splits?: any[]; splitMethod?: string
}
/** Get translated frequency description for a recurring payment */
export function frequencyDescription(payment: { frequency: string; cronExpression?: string }, lang: 'en' | 'de'): string {
export function frequencyDescription(payment: { frequency: string; cronExpression?: string }, lang: CospendLang): string {
switch (payment.frequency) {
case 'daily': return t('freq_every_day', lang);
case 'weekly': return t('freq_every_week', lang);
@@ -421,7 +154,7 @@ export function frequencyDescription(payment: { frequency: string; cronExpressio
}
/** Format next execution date with i18n */
export function formatNextExecutionI18n(date: Date, lang: 'en' | 'de'): string {
export function formatNextExecutionI18n(date: Date, lang: CospendLang): string {
const loc = locale(lang);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
+72
View File
@@ -0,0 +1,72 @@
/**
* Faith route i18n UI translations and slug mappings.
*
* Translation tables live per-locale in `$lib/i18n/faith/{de,en,la}.ts`.
* `de.ts` is the source of truth for the key set; `en.ts` and `la.ts` use
* `satisfies Record<keyof typeof de, string>` so any missing translation
* surfaces as a TypeScript error at build time.
*
* Faith routes get `lang` from layout server data (data.lang), derived from
* the [faithLang=faithLang] param matcher: glaubede, faithen, fidesla.
* Use `langFromFaithSlug(params.faithLang)` if you need it from the slug
* directly.
*/
import { de } from '$lib/i18n/faith/de';
import { en } from '$lib/i18n/faith/en';
import { la } from '$lib/i18n/faith/la';
/** All faith translations, keyed by locale. */
export const m = { de, en, la } as const;
export type FaithLang = keyof typeof m;
export type FaithKey = keyof typeof de;
/** Map a `[faithLang]` slug to the locale code. */
export function langFromFaithSlug(faithLang: string | null | undefined): FaithLang {
if (faithLang === 'faith') return 'en';
if (faithLang === 'fides') return 'la';
return 'de';
}
/** Reverse: locale → URL slug. */
export function faithSlugFromLang(lang: FaithLang): 'faith' | 'glaube' | 'fides' {
if (lang === 'en') return 'faith';
if (lang === 'la') return 'fides';
return 'glaube';
}
/** URL slug for the `[prayers=prayersLang]` segment per locale. */
export function prayersSlug(lang: FaithLang): 'prayers' | 'gebete' | 'orationes' {
if (lang === 'en') return 'prayers';
if (lang === 'la') return 'orationes';
return 'gebete';
}
/** URL slug for the `[rosary=rosaryLang]` segment per locale. */
export function rosarySlug(lang: FaithLang): 'rosary' | 'rosenkranz' | 'rosarium' {
if (lang === 'en') return 'rosary';
if (lang === 'la') return 'rosarium';
return 'rosenkranz';
}
/** URL slug for the `[calendar=calendarLang]` segment per locale. */
export function calendarSlug(lang: FaithLang): 'calendar' | 'kalender' | 'calendarium' {
if (lang === 'en') return 'calendar';
if (lang === 'la') return 'calendarium';
return 'kalender';
}
/** URL slug for the apologetik section per locale (no Latin variant — falls back to English). */
export function apologetikSlug(lang: FaithLang): 'apologetics' | 'apologetik' {
return lang === 'de' ? 'apologetik' : 'apologetics';
}
/**
* Get a translated string. Prefer `m[lang].key` directly in new code this
* helper is kept for incremental migration and falls back to English then
* the key itself if the lookup misses.
*/
export function t(key: FaithKey, lang: FaithLang): string {
return m[lang][key] ?? m.en[key] ?? key;
}
+4 -2
View File
@@ -1,7 +1,9 @@
import type { FitnessKey } from './fitnessI18n';
export type SingleBodyPartCard = {
key: string;
slugDe: string;
labelKey: string;
labelKey: FitnessKey;
img: string | null;
paired: false;
db: string;
@@ -9,7 +11,7 @@ export type SingleBodyPartCard = {
export type PairedBodyPartCard = {
key: string;
slugDe: string;
labelKey: string;
labelKey: FitnessKey;
img: string | null;
paired: true;
dbLeft: string;
+28 -466
View File
@@ -1,4 +1,20 @@
/** Fitness route i18n — slug mappings and UI translations */
/**
* Fitness route i18n slug mappings and UI translations.
*
* Translation tables live per-locale in `$lib/i18n/fitness/{de,en}.ts`.
* `de.ts` is the source of truth for the key set; `en.ts` uses
* `satisfies Record<keyof typeof de, string>` so any missing English
* translation surfaces as a TypeScript error at build time.
*/
import { de } from '$lib/i18n/fitness/de';
import { en } from '$lib/i18n/fitness/en';
/** All fitness translations, keyed by locale. */
export const m = { de, en } as const;
export type FitnessLang = keyof typeof m;
export type FitnessKey = keyof typeof de;
const slugMap: Record<string, Record<string, string>> = {
en: { statistik: 'stats', verlauf: 'history', training: 'workout', aktiv: 'active', uebungen: 'exercises', erfassung: 'check-in', ernaehrung: 'nutrition' },
@@ -8,7 +24,7 @@ const slugMap: Record<string, Record<string, string>> = {
const germanSlugs = new Set(Object.keys(slugMap.en));
/** Detect language from a fitness path by checking for any German slug */
export function detectFitnessLang(pathname: string): 'en' | 'de' {
export function detectFitnessLang(pathname: string): FitnessLang {
const segments = pathname.replace(/^\/fitness\/?/, '').split('/');
for (const seg of segments) {
if (germanSlugs.has(seg)) return 'de';
@@ -17,14 +33,14 @@ export function detectFitnessLang(pathname: string): 'en' | 'de' {
}
/** Convert a fitness path to the target language */
export function convertFitnessPath(pathname: string, targetLang: 'en' | 'de'): string {
export function convertFitnessPath(pathname: string, targetLang: FitnessLang): string {
const map = slugMap[targetLang];
const segments = pathname.split('/');
return segments.map(seg => map[seg] ?? seg).join('/');
}
/** Get translated sub-route slugs for a given language */
export function fitnessSlugs(lang: 'en' | 'de') {
export function fitnessSlugs(lang: FitnessLang) {
return {
stats: lang === 'en' ? 'stats' : 'statistik',
history: lang === 'en' ? 'history' : 'verlauf',
@@ -37,7 +53,7 @@ export function fitnessSlugs(lang: 'en' | 'de') {
}
/** Get translated nav labels */
export function fitnessLabels(lang: 'en' | 'de') {
export function fitnessLabels(lang: FitnessLang) {
return {
stats: lang === 'en' ? 'Stats' : 'Statistik',
history: lang === 'en' ? 'History' : 'Verlauf',
@@ -48,465 +64,11 @@ export function fitnessLabels(lang: 'en' | 'de') {
};
}
type Translations = Record<string, Record<string, string>>;
const translations: Translations = {
// Common
save: { en: 'Save', de: 'Speichern' },
saving: { en: 'Saving…', de: 'Speichern…' },
cancel: { en: 'CANCEL', de: 'ABBRECHEN' },
delete_: { en: 'Delete', de: 'Löschen' },
edit: { en: 'Edit', de: 'Bearbeiten' },
loading: { en: 'Loading…', de: 'Laden…' },
set: { en: 'set', de: 'Satz' },
sets: { en: 'sets', de: 'Sätze' },
exercise: { en: 'exercise', de: 'Übung' },
exercises_word: { en: 'exercises', de: 'Übungen' },
// Units
kg: { en: 'kg', de: 'kg' },
km: { en: 'km', de: 'km' },
min: { en: 'min', de: 'Min' },
// Stats page
stats_title: { en: 'Stats', de: 'Statistik' },
workout_singular: { en: 'Workout', de: 'Training' },
workouts_plural: { en: 'Workouts', de: 'Trainings' },
lifted: { en: 'Lifted', de: 'Gehoben' },
est_kcal: { en: 'Est. kcal', de: 'Gesch. kcal' },
burned: { en: 'Burned', de: 'Verbrannt' },
kcal_set_profile: { en: 'Set sex & height in', de: 'Geschlecht & Grösse unter' },
covered: { en: 'Covered', de: 'Zurückgelegt' },
workouts_per_week: { en: 'Workouts per week', de: 'Trainings pro Woche' },
sex: { en: 'Sex', de: 'Geschlecht' },
male: { en: 'Male', de: 'Männlich' },
female: { en: 'Female', de: 'Weiblich' },
height: { en: 'Height (cm)', de: 'Grösse (cm)' },
birth_year: { en: 'Birth Year', de: 'Geburtsjahr' },
no_workout_data: { en: 'No workout data to display yet.', de: 'Noch keine Trainingsdaten vorhanden.' },
weight: { en: 'Weight', de: 'Gewicht' },
// History page
history_title: { en: 'History', de: 'Verlauf' },
no_workouts_yet: { en: 'No workouts yet. Start your first workout!', de: 'Noch keine Trainings. Starte dein erstes Training!' },
load_more: { en: 'Load more', de: 'Mehr laden' },
// History detail
date: { en: 'Date', de: 'Datum' },
time: { en: 'Time', de: 'Uhrzeit' },
duration_min: { en: 'Duration (min)', de: 'Dauer (Min)' },
notes: { en: 'Notes', de: 'Notizen' },
notes_placeholder: { en: 'Workout notes...', de: 'Trainingsnotizen...' },
gps_track_stored: { en: 'GPS track stored', de: 'GPS-Track gespeichert' },
add_set: { en: '+ ADD SET', de: '+ SATZ HINZUFÜGEN' },
add_exercise: { en: '+ ADD EXERCISE', de: '+ ÜBUNG HINZUFÜGEN' },
splits: { en: 'Splits', de: 'Splits' },
pace: { en: 'PACE', de: 'TEMPO' },
upload_gpx: { en: 'Upload GPX', de: 'GPX hochladen' },
uploading: { en: 'Uploading...', de: 'Hochladen...' },
download_gpx: { en: 'Download GPX', de: 'GPX herunterladen' },
elevation: { en: 'Elevation', de: 'Höhenprofil' },
elevation_unit: { en: 'm', de: 'm' },
elevation_gain: { en: 'Gain', de: 'Anstieg' },
elevation_loss: { en: 'Loss', de: 'Abstieg' },
cadence: { en: 'Cadence', de: 'Kadenz' },
cadence_unit: { en: 'spm', de: 'spm' },
cadence_permission_missing: {
en: 'Cadence disabled — grant Activity Recognition in system settings',
de: 'Kadenz deaktiviert — Aktivitätserkennung in den Einstellungen erlauben'
},
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
recalc_title: { en: 'Recalculate volume, PRs, and GPS previews', de: 'Volumen, PRs und GPS-Vorschauen neu berechnen' },
// Workout templates page
next_in_schedule: { en: 'Next in schedule', de: 'Nächstes im Plan' },
start_empty_workout: { en: 'Empty Workout', de: 'leeres Training' },
templates: { en: 'Templates', de: 'Vorlagen' },
schedule: { en: 'Schedule', de: 'Zeitplan' },
my_templates: { en: 'My Templates', de: 'Meine Vorlagen' },
no_templates_yet: { en: 'No templates yet. Browse the library or create your own.', de: 'Noch keine Vorlagen. Stöbere in der Bibliothek oder erstelle deine eigene.' },
template_library: { en: 'Template Library', de: 'Vorlagen-Bibliothek' },
browse_library: { en: 'Browse Library', de: 'Bibliothek durchsuchen' },
template_added: { en: 'Template added', de: 'Vorlage hinzugefügt' },
edit_template: { en: 'Edit Template', de: 'Vorlage bearbeiten' },
new_template: { en: 'New Template', de: 'Neue Vorlage' },
template_name_placeholder: { en: 'Template name', de: 'Vorlagenname' },
add_set_lower: { en: '+ Add set', de: '+ Satz hinzufügen' },
add_exercise_btn: { en: 'Add Exercise', de: 'Übung hinzufügen' },
save_template: { en: 'Save Template', de: 'Vorlage speichern' },
workout_schedule: { en: 'Workout Schedule', de: 'Trainingsplan' },
schedule_hint: { en: 'Select templates and arrange their order. After completing a workout, the next one in the rotation will be suggested.', de: 'Wähle Vorlagen und ordne sie an. Nach Abschluss eines Trainings wird das nächste in der Rotation vorgeschlagen.' },
available_templates: { en: 'Available templates', de: 'Verfügbare Vorlagen' },
all_templates_scheduled: { en: 'All templates are in the schedule', de: 'Alle Vorlagen sind im Zeitplan' },
save_schedule: { en: 'Save Schedule', de: 'Zeitplan speichern' },
start_workout: { en: 'Start Workout', de: 'Training starten' },
delete_template: { en: 'Delete', de: 'Löschen' },
// Active workout / completion
workout_complete: { en: 'Workout Complete', de: 'Training abgeschlossen' },
workout_saved_offline: { en: 'Saved offline — will sync when back online.', de: 'Offline gespeichert — wird bei Verbindung synchronisiert.' },
duration: { en: 'Duration', de: 'Dauer' },
tonnage: { en: 'Tonnage', de: 'Tonnage' },
distance: { en: 'Distance', de: 'Distanz' },
exercises_heading: { en: 'Exercises', de: 'Übungen' },
volume: { en: 'volume', de: 'Volumen' },
avg: { en: 'avg', de: 'Ø' },
update_template: { en: 'Update Template', de: 'Vorlage aktualisieren' },
template_updated: { en: 'Template updated', de: 'Vorlage aktualisiert' },
template_diff_desc: { en: 'Your weights or reps differ from the template:', de: 'Gewichte oder Wiederholungen weichen von der Vorlage ab:' },
updating: { en: 'Updating...', de: 'Aktualisieren...' },
view_workout: { en: 'VIEW WORKOUT', de: 'TRAINING ANSEHEN' },
done: { en: 'DONE', de: 'FERTIG' },
workout_name_placeholder: { en: 'Workout name', de: 'Trainingsname' },
cancel_workout: { en: 'CANCEL WORKOUT', de: 'TRAINING ABBRECHEN' },
finish: { en: 'FINISH', de: 'BEENDEN' },
new_set_added: { en: 'new set', de: 'neuer Satz' },
new_sets_added: { en: 'new sets', de: 'neue Sätze' },
// Exercises page
exercises_title: { en: 'Exercises', de: 'Übungen' },
search_exercises: { en: 'Search exercises…', de: 'Übungen suchen…' },
no_exercises_match: { en: 'No exercises match your search.', de: 'Keine Übungen gefunden.' },
type_any: { en: 'Any type', de: 'Alle Arten' },
type_weights: { en: 'Strength', de: 'Kraft' },
type_stretches: { en: 'Stretches', de: 'Dehnen' },
stretch_pill: { en: 'Stretch', de: 'Dehnung' },
strength_pill: { en: 'Strength', de: 'Kraft' },
cardio_pill: { en: 'Cardio', de: 'Cardio' },
plyo_pill: { en: 'Plyo', de: 'Plyo' },
// Exercise detail
about: { en: 'ABOUT', de: 'INFO' },
history_tab: { en: 'HISTORY', de: 'VERLAUF' },
charts: { en: 'CHARTS', de: 'DIAGRAMME' },
records: { en: 'RECORDS', de: 'REKORDE' },
instructions: { en: 'Instructions', de: 'Anleitung' },
no_history_yet: { en: 'No history for this exercise yet.', de: 'Noch kein Verlauf für diese Übung.' },
est_1rm: { en: 'EST. 1RM', de: 'GESCH. 1RM' },
best_set_1rm: { en: 'Best Set (Est. 1RM)', de: 'Bester Satz (Gesch. 1RM)' },
best_set_max: { en: 'Best Set (Max Weight)', de: 'Bester Satz (Max. Gewicht)' },
total_volume: { en: 'Total Volume', de: 'Gesamtvolumen' },
not_enough_data: { en: 'Not enough data to display charts yet.', de: 'Noch nicht genug Daten für Diagramme.' },
estimated_1rm: { en: 'Estimated 1RM', de: 'Geschätztes 1RM' },
max_volume: { en: 'Max Volume', de: 'Max. Volumen' },
max_weight: { en: 'Max Weight', de: 'Max. Gewicht' },
rep_records: { en: 'Rep Records', de: 'Wiederholungsrekorde' },
reps: { en: 'REPS', de: 'WDH' },
best_performance: { en: 'BEST PERFORMANCE', de: 'BESTLEISTUNG' },
// Measure page
measure_title: { en: 'Measure', de: 'Messen' },
profile: { en: 'Profile', de: 'Profil' },
profile_setup_cta: {
en: 'Add height & birth year to unlock BMI, TDEE and calorie balance stats.',
de: 'Größe & Geburtsjahr eintragen, um BMI, TDEE und Kalorienbilanz freizuschalten.'
},
set_up_profile: { en: 'Set up', de: 'Einrichten' },
edit_profile: { en: 'Edit profile', de: 'Profil bearbeiten' },
dismiss: { en: 'Dismiss', de: 'Verwerfen' },
new_measurement: { en: 'New Measurement', de: 'Neue Messung' },
edit_measurement: { en: 'Edit Measurement', de: 'Messung bearbeiten' },
weight_kg: { en: 'Weight (kg)', de: 'Gewicht (kg)' },
body_fat: { en: 'Body Fat %', de: 'Körperfett %' },
calories_kcal: { en: 'Calories (kcal)', de: 'Kalorien (kcal)' },
body_parts_cm: { en: 'Body Parts (cm)', de: 'Körpermasse (cm)' },
neck: { en: 'Neck', de: 'Hals' },
shoulders: { en: 'Shoulders', de: 'Schultern' },
chest: { en: 'Chest', de: 'Brust' },
l_bicep: { en: 'L Bicep', de: 'L Bizeps' },
r_bicep: { en: 'R Bicep', de: 'R Bizeps' },
l_forearm: { en: 'L Forearm', de: 'L Unterarm' },
r_forearm: { en: 'R Forearm', de: 'R Unterarm' },
waist: { en: 'Waist', de: 'Taille' },
hips: { en: 'Hips', de: 'Hüfte' },
l_thigh: { en: 'L Thigh', de: 'L Oberschenkel' },
r_thigh: { en: 'R Thigh', de: 'R Oberschenkel' },
l_calf: { en: 'L Calf', de: 'L Wade' },
r_calf: { en: 'R Calf', de: 'R Wade' },
biceps: { en: 'Biceps', de: 'Bizeps' },
forearms: { en: 'Forearms', de: 'Unterarme' },
thighs: { en: 'Thighs', de: 'Oberschenkel' },
calves: { en: 'Calves', de: 'Waden' },
measure_tip_neck: {
en: 'Just below the Adam\u2019s apple, tape parallel to the floor.',
de: 'Direkt unter dem Adamsapfel, Band parallel zum Boden.'
},
measure_tip_shoulders: {
en: 'Widest point across the deltoids, arms relaxed at your sides.',
de: 'Breiteste Stelle \u00fcber die Schultern, Arme entspannt h\u00e4ngend.'
},
measure_tip_chest: {
en: 'At nipple line after a normal exhale, tape horizontal.',
de: 'In Brustwarzenh\u00f6he nach normalem Ausatmen, Band waagerecht.'
},
measure_tip_biceps: {
en: 'Arm flexed at the peak; tape around the thickest part.',
de: 'Arm angespannt im Peak; um die dickste Stelle messen.'
},
measure_tip_forearms: {
en: 'Widest point below the elbow, arm hanging relaxed.',
de: 'Breiteste Stelle unterhalb des Ellenbogens, Arm entspannt.'
},
measure_tip_waist: {
en: 'At the navel, relaxed \u2014 don\u2019t suck in.',
de: 'In Nabelh\u00f6he, locker \u2014 nicht einziehen.'
},
measure_tip_hips: {
en: 'Around the widest point of the buttocks.',
de: 'Um die breiteste Stelle des Ges\u00e4\u00dfes.'
},
measure_tip_thighs: {
en: 'Midway between hip crease and knee.',
de: 'Mittig zwischen Leistenfalte und Knie.'
},
measure_tip_calves: {
en: 'Widest point, standing with weight on both feet.',
de: 'Breiteste Stelle, beidseitig belastet stehend.'
},
save_measurement: { en: 'Save Measurement', de: 'Messung speichern' },
update_measurement: { en: 'Update Measurement', de: 'Messung aktualisieren' },
measure_body_parts: { en: 'Measure body parts', de: 'Körpermasse erfassen' },
measure_body_parts_sub: {
en: 'Guided tape-measure flow \u2014 one part at a time.',
de: 'Gef\u00fchrter Ablauf \u2014 ein K\u00f6rperteil nach dem anderen.'
},
last_measured: { en: 'Last measured', de: 'Zuletzt gemessen' },
no_measurements_yet: { en: 'No measurements yet', de: 'Noch keine Messungen' },
step_n_of_m: { en: 'Step {n} of {m}', de: 'Schritt {n} von {m}' },
over_time: { en: '{label} over time', de: '{label} im Verlauf' },
first_measurement_hint: {
en: 'First measurement \u2014 your entry will appear here.',
de: 'Erste Messung \u2014 dein Wert erscheint hier.'
},
running_totals: { en: 'Running totals', de: 'Laufende \u00dcbersicht' },
review_save: { en: 'Review & save', de: 'Pr\u00fcfen & speichern' },
ready_to_save: { en: 'Ready to save', de: 'Bereit zum Speichern' },
review_numbers: { en: 'Review your numbers below.', de: 'Pr\u00fcfe deine Werte unten.' },
skip: { en: 'Skip', de: 'Auslassen' },
next: { en: 'Next', de: 'Weiter' },
back: { en: 'Back', de: 'Zur\u00fcck' },
review: { en: 'Review', de: 'Pr\u00fcfen' },
edit_again: { en: 'Edit again', de: 'Erneut bearbeiten' },
exit: { en: 'Exit', de: 'Schlie\u00dfen' },
same_both_sides: { en: 'Same on both sides', de: 'Auf beiden Seiten gleich' },
copy_l_to_r: { en: 'Copy L \u2192 R', de: 'L \u2192 R \u00fcbernehmen' },
copy_l_to_r_before: { en: 'Copy L', de: 'L' },
copy_l_to_r_after: { en: 'R', de: 'R \u00fcbernehmen' },
kbd_nav: { en: 'nav', de: 'Navigation' },
kbd_next: { en: 'next', de: 'weiter' },
kbd_skip: { en: 'skip', de: 'auslassen' },
kbd_wheel: { en: '\u00b10.1', de: '\u00b10,1' },
kbd_hint: { en: 'Press ? for shortcuts', de: '? dr\u00fccken f\u00fcr Tastenk\u00fcrzel' },
no_body_parts_selected: {
en: 'Enter at least one value before saving.',
de: 'Bitte mindestens einen Wert eingeben.'
},
today_short: { en: 'today', de: 'heute' },
latest: { en: 'Latest', de: 'Aktuell' },
body_fat_short: { en: 'Body Fat', de: 'Körperfett' },
calories: { en: 'Calories', de: 'Kalorien' },
body_parts: { en: 'Body Parts', de: 'Körpermasse' },
body_measurements_only: { en: 'Body measurements only', de: 'Nur Körpermasse' },
delete_measurement_confirm: { en: 'Delete this measurement?', de: 'Diese Messung löschen?' },
general: { en: 'General', de: 'Allgemein' },
body_fat_pct: { en: 'Body Fat (%)', de: 'Körperfett (%)' },
history: { en: 'History', de: 'Verlauf' },
past_measurements: { en: 'Past measurements', de: 'Frühere Messungen' },
show_more: { en: 'Show more', de: 'Mehr anzeigen' },
overwrite_title: { en: 'Overwrite existing values?', de: 'Bestehende Werte überschreiben?' },
overwrite_message: {
en: 'You already have values for this date: {fields}. Replace them?',
de: 'Für dieses Datum sind bereits Werte erfasst: {fields}. Überschreiben?'
},
overwrite_confirm: { en: 'Overwrite', de: 'Überschreiben' },
same_as_last: { en: 'Same as last', de: 'Wie zuletzt' },
// SetTable
set_header: { en: 'SET', de: 'SATZ' },
prev_header: { en: 'PREV', de: 'VORH' },
rpe: { en: 'RPE', de: 'RPE' },
// ExercisePicker
picker_title: { en: 'Add Exercise', de: 'Übung hinzufügen' },
no_exercises_found: { en: 'No exercises found', de: 'Keine Übungen gefunden' },
// TemplateCard
last_performed: { en: 'Last performed:', de: 'Zuletzt durchgeführt:' },
today: { en: 'Today', de: 'Heute' },
yesterday: { en: 'Yesterday', de: 'Gestern' },
days_ago: { en: 'days ago', de: 'Tagen' },
more: { en: 'more', de: 'weitere' },
// WorkoutFab
active_workout: { en: 'Active Workout', de: 'Aktives Training' },
// Streak / Goal
streak: { en: 'Streak', de: 'Serie' },
streak_weeks: { en: 'Weeks', de: 'Wochen' },
streak_week: { en: 'Week', de: 'Woche' },
weekly_goal: { en: 'Weekly Goal', de: 'Wochenziel' },
workouts_per_week_goal: { en: 'workouts / week', de: 'Trainings / Woche' },
set_goal: { en: 'Set Goal', de: 'Ziel setzen' },
goal_set: { en: 'Goal set', de: 'Ziel gesetzt' },
// Intervals
intervals: { en: 'Intervals', de: 'Intervalle' },
no_intervals: { en: 'None', de: 'Keine' },
new_interval: { en: 'New Interval', de: 'Neues Intervall' },
edit_interval: { en: 'Edit Interval', de: 'Intervall bearbeiten' },
delete_interval: { en: 'Delete', de: 'Löschen' },
delete_interval_confirm: { en: 'Delete this interval template?', de: 'Diese Intervallvorlage löschen?' },
add_step: { en: '+ Add Step', de: '+ Schritt hinzufügen' },
add_group: { en: '+ Add Repeat Group', de: '+ Wiederholungsgruppe' },
repeat_times: { en: 'times', de: 'mal' },
ungroup: { en: 'Ungroup', de: 'Auflösen' },
group_label: { en: 'Repeat', de: 'Wiederholen' },
step_label: { en: 'Label', de: 'Bezeichnung' },
meters: { en: 'meters', de: 'Meter' },
seconds: { en: 'seconds', de: 'Sekunden' },
intervals_complete: { en: 'Intervals complete', de: 'Intervalle abgeschlossen' },
select_interval: { en: 'Select Interval', de: 'Intervall wählen' },
custom: { en: 'Custom', de: 'Eigene' },
steps_count: { en: 'steps', de: 'Schritte' },
save_interval: { en: 'Save Interval', de: 'Intervall speichern' },
interval_name_placeholder: { en: 'Interval name', de: 'Intervallname' },
// Preset labels
label_easy: { en: 'Easy', de: 'Leicht' },
label_moderate: { en: 'Moderate', de: 'Moderat' },
label_hard: { en: 'Hard', de: 'Hart' },
label_sprint: { en: 'Sprint', de: 'Sprint' },
label_recovery: { en: 'Recovery', de: 'Erholung' },
label_hill_sprints: { en: 'Hill Sprints', de: 'Bergsprints' },
label_tempo: { en: 'Tempo', de: 'Tempo' },
label_warm_up: { en: 'Warm Up', de: 'Aufwärmen' },
label_cool_down: { en: 'Cool Down', de: 'Abkühlen' },
// Nutrition / Food log
nutrition_title: { en: 'Nutrition', de: 'Ernährung' },
breakfast: { en: 'Breakfast', de: 'Frühstück' },
lunch: { en: 'Lunch', de: 'Mittagessen' },
dinner: { en: 'Dinner', de: 'Abendessen' },
snack: { en: 'Snack', de: 'Snack' },
add_food: { en: 'Add food', de: 'Essen hinzufügen' },
search_food: { en: 'Search food…', de: 'Essen suchen…' },
amount_grams: { en: 'Amount (g)', de: 'Menge (g)' },
meal_type: { en: 'Meal', de: 'Mahlzeit' },
daily_goal: { en: 'Daily Goal', de: 'Tagesziel' },
calorie_target: { en: 'Calorie target (kcal)', de: 'Kalorienziel (kcal)' },
protein_goal: { en: 'Protein goal', de: 'Proteinziel' },
protein_fixed: { en: 'Fixed (g/day)', de: 'Fest (g/Tag)' },
protein_per_kg: { en: 'Per kg bodyweight', de: 'Pro kg Körpergewicht' },
fat_percent: { en: 'Fat ratio', de: 'Fett-Anteil' },
carb_percent: { en: 'Carbs ratio', de: 'KH-Anteil' },
kcal: { en: 'kcal', de: 'kcal' },
protein: { en: 'Protein', de: 'Protein' },
fat: { en: 'Fat', de: 'Fett' },
carbs: { en: 'Carbs', de: 'Kohlenhydrate' },
remaining: { en: 'left', de: 'übrig' },
over: { en: 'over', de: 'über' },
no_entries_yet: { en: 'No entries yet. Add food to start tracking.', de: 'Noch keine Einträge. Füge Essen hinzu, um zu tracken.' },
set_goal_prompt: { en: 'Set a daily calorie goal to start tracking.', de: 'Setze ein Kalorienziel, um mit dem Tracking zu beginnen.' },
micro_details: { en: 'Micronutrients', de: 'Mikronährstoffe' },
of_daily: { en: 'of daily goal', de: 'vom Tagesziel' },
per_serving: { en: 'per serving', de: 'pro Portion' },
log_food: { en: 'Log', de: 'Eintragen' },
delete_entry_confirm: { en: 'Delete this food entry?', de: 'Diesen Eintrag löschen?' },
// Period tracker
period_tracker: { en: 'Period Tracker', de: 'Periodentracker' },
current_period: { en: 'Current Period', de: 'Aktuelle Periode' },
no_period_data: { en: 'No period data yet. Log your first period to start tracking.', de: 'Noch keine Periodendaten. Erfasse deine erste Periode.' },
no_active_period: { en: 'No active period.', de: 'Keine aktive Periode.' },
start_period: { en: 'Start Period', de: 'Periode starten' },
end_period: { en: 'Period Ended', de: 'Periode vorbei' },
period_day: { en: 'Day', de: 'Tag' },
predicted_end: { en: 'Predicted end', de: 'Voraussichtliches Ende' },
next_period: { en: 'Next period', de: 'Nächste Periode' },
cycle_length: { en: 'Cycle length', de: 'Zykluslänge' },
period_length: { en: 'Period length', de: 'Periodenlänge' },
avg_cycle: { en: 'Avg. cycle', de: 'Ø Zyklus' },
avg_period: { en: 'Avg. period', de: 'Ø Periode' },
days: { en: 'days', de: 'Tage' },
delete_period_confirm: { en: 'Delete this period entry?', de: 'Diesen Periodeneintrag löschen?' },
add_past_period: { en: 'Add Past Period', de: 'Vergangene Periode hinzufügen' },
period_start: { en: 'Start', de: 'Beginn' },
period_end: { en: 'End', de: 'Ende' },
ongoing: { en: 'ongoing', de: 'laufend' },
share: { en: 'Share', de: 'Teilen' },
shared_with: { en: 'Shared with', de: 'Geteilt mit' },
add_user: { en: 'Add user…', de: 'Nutzer hinzufügen…' },
no_shared: { en: 'Not shared with anyone.', de: 'Mit niemandem geteilt.' },
shared_by: { en: 'Shared by', de: 'Geteilt von' },
fertile_window: { en: 'Fertile window', de: 'Fruchtbares Fenster' },
peak_fertility: { en: 'Peak fertility', de: 'Höchste Fruchtbarkeit' },
ovulation: { en: 'Ovulation', de: 'Eisprung' },
fertile: { en: 'Fertile', de: 'Fruchtbar' },
luteal_phase: { en: 'Luteal', de: 'Luteal' },
predicted_ovulation: { en: 'Predicted ovulation', de: 'Voraussichtlicher Eisprung' },
to: { en: 'to', de: 'bis' },
// Exercise detail (enriched)
overview: { en: 'Overview', de: 'Überblick' },
tips: { en: 'Tips', de: 'Tipps' },
similar_exercises: { en: 'Similar Exercises', de: 'Ähnliche Übungen' },
primary_muscles: { en: 'Primary', de: 'Primär' },
secondary_muscles: { en: 'Secondary', de: 'Sekundär' },
play_video: { en: 'Play Video', de: 'Video abspielen' },
// Nutrition stats
nutrition_stats: { en: 'Nutrition', de: 'Ernährung' },
protein_per_kg_unit: { en: 'g/kg', de: 'g/kg' },
calorie_balance: { en: 'Calorie Balance', de: 'Kalorienbilanz' },
calorie_balance_unit: { en: 'kcal/day', de: 'kcal/Tag' },
diet_adherence: { en: 'Adherence', de: 'Einhaltung' },
seven_day_avg: { en: '7-day avg', de: '7-Tage-Ø' },
thirty_day: { en: '30 days', de: '30 Tage' },
macro_split: { en: 'Macro Split', de: 'Makroverteilung' },
no_nutrition_data: { en: 'No nutrition data yet. Start logging food to see stats.', de: 'Noch keine Ernährungsdaten. Beginne mit dem Tracking.' },
target: { en: 'Target', de: 'Ziel' },
days_tracked: { en: 'days tracked', de: 'Tage erfasst' },
since_start: { en: 'Since start', de: 'Seit Beginn' },
no_weight_data: { en: 'Log weight to enable', de: 'Gewicht eintragen' },
no_calorie_goal: { en: 'Set calorie goal', de: 'Kalorienziel setzen' },
// Muscle heatmap
muscle_balance: { en: 'Muscle Balance', de: 'Muskelbalance' },
weekly_sets: { en: 'Sets per week', de: 'Sätze pro Woche' },
// Custom meals
custom_meals: { en: 'Custom Meals', de: 'Eigene Mahlzeiten' },
custom_meal: { en: 'Custom Meal', de: 'Eigene Mahlzeit' },
new_meal: { en: 'New Meal', de: 'Neue Mahlzeit' },
meal_name: { en: 'Meal name', de: 'Name der Mahlzeit' },
add_ingredient: { en: 'Add ingredient', de: 'Zutat hinzufügen' },
no_custom_meals: { en: 'No custom meals yet.', de: 'Noch keine eigenen Mahlzeiten.' },
create_meal_hint: { en: 'Create reusable meals for quick logging.', de: 'Erstelle wiederverwendbare Mahlzeiten zum schnellen Eintragen.' },
ingredients: { en: 'Ingredients', de: 'Zutaten' },
total: { en: 'Total', de: 'Gesamt' },
log_meal: { en: 'Log Meal', de: 'Mahlzeit eintragen' },
delete_meal_confirm: { en: 'Delete this custom meal?', de: 'Diese Mahlzeit löschen?' },
save_meal: { en: 'Save Meal', de: 'Mahlzeit speichern' },
// Favorites
favorites: { en: 'Favorites', de: 'Favoriten' },
// Ingredient detail
per_100g: { en: 'per 100 g', de: 'pro 100 g' },
macros: { en: 'Macronutrients', de: 'Makronährstoffe' },
minerals: { en: 'Minerals', de: 'Mineralstoffe' },
vitamins: { en: 'Vitamins', de: 'Vitamine' },
amino_acids: { en: 'Amino Acids', de: 'Aminosäuren' },
essential: { en: 'Essential', de: 'Essenziell' },
non_essential: { en: 'Non-Essential', de: 'Nicht-essenziell' },
saturated_fat: { en: 'Saturated Fat', de: 'Gesättigte Fettsäuren' },
fiber: { en: 'Fiber', de: 'Ballaststoffe' },
sugars: { en: 'Sugars', de: 'Zucker' },
source_db: { en: 'Source', de: 'Quelle' },
};
/** Get a translated string */
export function t(key: string, lang: 'en' | 'de'): string {
return translations[key]?.[lang] ?? translations[key]?.en ?? key;
/**
* Get a translated string. Prefer `m[lang].key` directly in new code this
* helper is kept for the existing call sites and falls back to English then
* the key itself if the lookup misses.
*/
export function t(key: FitnessKey, lang: FitnessLang): string {
return m[lang][key] ?? m.en[key] ?? key;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Recipes route i18n.
*
* Translation tables live per-locale in `$lib/i18n/recipes/{de,en}.ts`.
* `de.ts` is the source of truth for the key set; `en.ts` uses
* `satisfies Record<keyof typeof de, string>` so missing translations
* fail the build.
*
* Recipes routes get `lang` from the layout server load (data.lang),
* derived from the [recipeLang=recipeLang] param matcher: rezeptede,
* recipesen. Use `langFromRecipeSlug(params.recipeLang)` if you need it
* from the slug directly.
*/
import { de } from '$lib/i18n/recipes/de';
import { en } from '$lib/i18n/recipes/en';
export const m = { de, en } as const;
export type RecipesLang = keyof typeof m;
export type RecipesKey = keyof typeof de;
/** Map a `[recipeLang]` slug to the locale code. */
export function langFromRecipeSlug(recipeLang: string | null | undefined): RecipesLang {
return recipeLang === 'recipes' ? 'en' : 'de';
}
/** Reverse: locale → URL slug. */
export function recipeSlugFromLang(lang: RecipesLang): 'recipes' | 'rezepte' {
return lang === 'en' ? 'recipes' : 'rezepte';
}
/**
* Get a translated string. Prefer `m[lang].key` directly in new code
* this helper is kept for incremental migration.
*/
export function t(key: RecipesKey, lang: RecipesLang): string {
return m[lang][key] ?? m.en[key] ?? key;
}
+15 -13
View File
@@ -34,33 +34,35 @@
};
});
import { m } from '$lib/js/commonI18n';
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en');
const labels = $derived({
welcome: isEnglish ? 'Welcome to bocken.org' : 'Willkommen auf bocken.org',
welcome: t.welcome,
intro1: isEnglish
? 'Hello, I\'m Alexander Bocken. On this website you\'ll find some software projects for friends, family, and myself. Everything is self-hosted at my home on a small mini-server (Arch, btw).'
: 'Hallo, ich bin Alexander Bocken. Auf dieser Seite findest du einige Softwareprojekte für Freunde, Familie und mich. Alles ist selbst gehostet bei mir daheim auf einem kleinen Mini-Server (Arch, btw).',
intro2: isEnglish
? 'I recommend my continuously growing recipe collection. There you\'ll find many delicious recipes that I\'ve tried myself and constantly refine. You\'re also welcome to use my search engine or Jitsi instance for video conferences. Some things are hidden behind a login, others are publicly accessible. If you know a bit about programming, feel free to browse my Git repositories.'
: 'Zu empfehlen ist meine stetig wachsende Rezeptsammlung. Dort findest du viele leckere Rezepte, die ich selbst ausprobiert habe und ständig weiterfeilsche. Zudem kannst du gerne meine Suchmaschine oder auch Jitsi-instanz für Videokonferenzen nutzen. Einiges ist hinter einem Login versteckt, anderes ist öffentlich zugänglich. Wer sich ein bisschen mit Programmieren auskennt, kann auch gerne in meinen Git-Repositories stöbern.',
pages: isEnglish ? 'Pages' : 'Seiten',
recipes: isEnglish ? 'Recipes' : 'Rezepte',
pages: t.pages,
recipes: t.recipes,
git: 'Git',
streaming: 'Streaming',
familyPhotos: isEnglish ? 'Family Photos' : 'Familienbilder',
familyPhotos: t.family_photos,
cloud: 'Cloud',
videoConferences: isEnglish ? 'Video Conferences' : 'Videokonferenzen',
searchEngine: isEnglish ? 'Search Engine' : 'Suchmaschine',
shopping: isEnglish ? 'Shopping' : 'Einkauf',
familyTree: isEnglish ? 'Family Tree' : 'Stammbaum',
faith: isEnglish ? 'Faith' : 'Glaube',
videoConferences: t.video_conferences,
searchEngine: t.search_engine,
shopping: t.shopping,
familyTree: t.family_tree,
faith: t.faith,
chat: 'Chat',
transmission: 'Transmission',
documents: isEnglish ? 'Documents' : 'Dokumente',
audiobooksPodcasts: isEnglish ? 'Audiobooks & Podcasts' : 'Hörbücher & Podcasts',
documents: t.documents,
audiobooksPodcasts: t.audiobooks_podcasts,
fitness: 'Fitness',
nutrition: isEnglish ? 'Nutrition' : 'Ernährung',
tasks: isEnglish ? 'Tasks' : 'Aufgaben'
nutrition: t.nutrition,
tasks: t.tasks
});
</script>
<style>
@@ -14,10 +14,11 @@
import { formatCurrency } from '$lib/utils/formatters';
import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, paymentCategoryName, m } from '$lib/js/cospendI18n';
let { data } = $props(); // Contains session data and balance from server
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -125,11 +126,11 @@
</script>
<svelte:head>
<title>{t('cospend_title', lang)}</title>
<title>{t.cospend_title}</title>
</svelte:head>
<main class="cospend-main">
<h1 class="sr-only">{t('cospend', lang)}</h1>
<h1 class="sr-only">{t.cospend}</h1>
<!-- Responsive layout for balance and chart -->
<div class="dashboard-layout">
@@ -138,7 +139,7 @@
<div class="actions">
{#if balance.netBalance !== 0}
<a href={resolve('/[cospendRoot=cospendRoot]/settle', { cospendRoot: root })} class="btn btn-settlement">{t('settle_debts', lang)}</a>
<a href={resolve('/[cospendRoot=cospendRoot]/settle', { cospendRoot: root })} class="btn btn-settlement">{t.settle_debts}</a>
{/if}
</div>
@@ -148,11 +149,11 @@
<!-- Monthly Expenses Chart -->
<div class="chart-section">
{#if expensesLoading}
<div class="loading">{t('loading_monthly', lang)}</div>
<div class="loading">{t.loading_monthly}</div>
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
<BarChart
data={monthlyExpensesData}
title={t('monthly_expenses_chart', lang)}
title={t.monthly_expenses_chart}
height="400px"
{lang}
onFilterChange={(/** @type {string[] | null} */ categories) => categoryFilter = categories}
@@ -168,19 +169,19 @@
</div>
{#if loading}
<div class="loading">{t('loading_recent', lang)}</div>
<div class="loading">{t.loading_recent}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if balance.recentSplits && balance.recentSplits.length > 0}
<div class="recent-activity">
<div class="recent-activity-header">
<h2>{t('recent_activity', lang)}{#if categoryFilter} <span class="filter-label">{categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ')}</span>{/if}</h2>
<h2>{t.recent_activity}{#if categoryFilter} <span class="filter-label">{categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ')}</span>{/if}</h2>
{#if categoryFilter}
<button class="clear-filter" onclick={() => categoryFilter = null}>{t('clear_filter', lang)}</button>
<button class="clear-filter" onclick={() => categoryFilter = null}>{t.clear_filter}</button>
{/if}
</div>
{#if filteredSplits.length === 0}
<p class="no-results">{t('no_recent_in', lang)} {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ') : ''}.</p>
<p class="no-results">{t.no_recent_in} {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ') : ''}.</p>
{/if}
<div class="activity-dialog">
{#each filteredSplits as split}
@@ -226,9 +227,9 @@
<div class="user-info">
<div class="payment-title-row">
<span class="category-emoji">{getCategoryEmoji(split.paymentId?.category || 'groceries')}</span>
<strong class="payment-title">{split.paymentId?.title || t('payment', lang)}</strong>
<strong class="payment-title">{split.paymentId?.title || t.payment}</strong>
</div>
<span class="username">{t('paid_by', lang)} {split.paymentId?.paidBy || 'Unknown'}</span>
<span class="username">{t.paid_by} {split.paymentId?.paidBy || 'Unknown'}</span>
<span class="category-name">{paymentCategoryName(split.paymentId?.category || 'groceries', lang)}</span>
</div>
<div class="activity-amount"
@@ -35,7 +35,7 @@
import Check from '@lucide/svelte/icons/check';
import { page } from '$app/state';
import { detectCospendLang, t, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions } from '$lib/js/cospendI18n';
import { detectCospendLang, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions, m } from '$lib/js/cospendI18n';
let { data } = $props();
let user = $derived(data.session?.user?.nickname || 'guest');
@@ -51,6 +51,7 @@
});
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const loc = $derived(locale(lang));
/** @type {Record<string, { icon: typeof Plus, color: string }>} */
@@ -427,7 +428,7 @@
<div class="shopping-page">
<header class="page-header">
<div class="header-row">
<h1 class="sr-only">{t('shopping_list_title', lang)}</h1>
<h1 class="sr-only">{t.shopping_list_title}</h1>
<SyncIndicator status={sync.status} />
{#if hasSupercard}
<button class="btn-card btn-card-coop" onclick={() => activeCard = 'supercard'} title="Coop Supercard" aria-label="Coop Supercard">
@@ -440,13 +441,13 @@
</button>
{/if}
{#if !isGuest}
<button class="btn-share" onclick={openShareModal} title={t('share', lang)}>
<button class="btn-share" onclick={openShareModal} title={t.share}>
<Share2 size={16} />
</button>
{/if}
</div>
{#if totalCount > 0}
<p class="subtitle">{checkedCount} / {totalCount} {t('items_done', lang)}</p>
<p class="subtitle">{checkedCount} / {totalCount} {t.items_done}</p>
{/if}
<div class="store-picker">
<Store size={13} />
@@ -466,7 +467,7 @@
bind:value={newItemName}
onkeydown={onKeydown}
type="text"
placeholder={t('add_item_placeholder', lang)}
placeholder={t.add_item_placeholder}
autocomplete="off"
/>
<button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}>
@@ -475,7 +476,7 @@
</div>
{#if totalCount === 0}
<p class="empty-state">{t('empty_list', lang)}</p>
<p class="empty-state">{t.empty_list}</p>
{:else}
<div class="item-list">
{#each groupedItems as group (group.category)}
@@ -528,7 +529,7 @@
{#if checkedCount > 0}
<button class="btn-clear-checked" onclick={() => sync.clearChecked()}>
<ListX size={16} />
{t('clear_checked', lang)} ({checkedCount})
{t.clear_checked} ({checkedCount})
</button>
{/if}
{/if}
@@ -549,18 +550,18 @@
<div class="name-qty-row">
<div class="field name-field">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('edit_name', lang)}</label>
<label class="edit-label">{t.edit_name}</label>
<input class="edit-input" type="text" bind:value={editName} />
</div>
<div class="field qty-field">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('edit_qty', lang)}</label>
<input class="edit-input" type="text" bind:value={editQty} placeholder={t('edit_qty_ph', lang)} />
<label class="edit-label">{t.edit_qty}</label>
<input class="edit-input" type="text" bind:value={editQty} placeholder={t.edit_qty_ph} />
</div>
</div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('kategorie', lang)}</label>
<label class="edit-label">{t.kategorie}</label>
<div class="category-picker">
{#each SHOPPING_CATEGORIES as cat}
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
@@ -578,10 +579,10 @@
</div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('icon', lang)}</label>
<label class="edit-label">{t.icon}</label>
<div class="icon-search">
<Search size={14} />
<input bind:value={iconSearch} type="text" placeholder={t('search_icon', lang)} />
<input bind:value={iconSearch} type="text" placeholder={t.search_icon} />
</div>
<div class="icon-picker">
{#each filteredIconGroups as [cat, icons]}
@@ -605,9 +606,9 @@
</div>
<div class="edit-actions">
<button class="btn-cancel" onclick={closeEdit}>{t('cancel', lang)}</button>
<button class="btn-cancel" onclick={closeEdit}>{t.cancel}</button>
<button class="btn-save" onclick={saveEdit} disabled={editSaving}>
{editSaving ? t('saving', lang) : t('save', lang)}
{editSaving ? t.saving : t.save}
</button>
</div>
</div>
@@ -622,17 +623,17 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="edit-modal share-modal" onclick={(e) => e.stopPropagation()}>
<div class="share-header">
<h3>{t('shared_links', lang)}</h3>
<h3>{t.shared_links}</h3>
<button class="close-button" onclick={() => { showShareModal = false; }}>
<X size={18} />
</button>
</div>
<p class="share-desc">{t('share_desc', lang)}</p>
<p class="share-desc">{t.share_desc}</p>
{#if shareLoading}
<p class="share-loading">{t('loading', lang)}</p>
<p class="share-loading">{t.loading}</p>
{:else if shareTokens.length === 0}
<p class="share-empty">{t('no_active_links', lang)}</p>
<p class="share-empty">{t.no_active_links}</p>
{:else}
<div class="token-list">
{#each shareTokens as tok (tok.id)}
@@ -642,7 +643,7 @@
<div class="token-expiry-row">
<span class="token-ttl">{formatTTL(tok.expiresAt)}</span>
<select class="token-ttl-select" onchange={(e) => onTTLChange(tok.id, e)}>
<option value="" disabled selected>{t('change', lang)}</option>
<option value="" disabled selected>{t.change}</option>
{#each TTL_OPTIONS as opt}
<option value={opt.ms}>{opt.label}</option>
{/each}
@@ -650,10 +651,10 @@
</div>
</div>
<div class="token-actions">
<button class="btn-token-copy" onclick={() => copyTokenLink(tok)} title={t('copy_link', lang)}>
<button class="btn-token-copy" onclick={() => copyTokenLink(tok)} title={t.copy_link}>
{#if copiedId === tok.id}<Check size={14} />{:else}<Copy size={14} />{/if}
</button>
<button class="btn-token-delete" onclick={() => deleteToken(tok.id)} title={t('delete_', lang)}>
<button class="btn-token-delete" onclick={() => deleteToken(tok.id)} title={t.delete_}>
<X size={14} />
</button>
</div>
@@ -664,7 +665,7 @@
<button class="btn-new-token" onclick={createNewToken}>
<Plus size={14} />
{t('create_new_link', lang)}
{t.create_new_link}
</button>
</div>
</div>
@@ -672,7 +673,7 @@
{#if showCopyToast}
<div class="copy-toast" transition:slide={{ duration: 150 }}>
<Check size={14} /> {t('copied', lang)}
<Check size={14} /> {t.copied}
</div>
{/if}
@@ -9,12 +9,13 @@
import { confirm } from '$lib/js/confirmDialog.svelte';
import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements';
import AddButton from '$lib/components/AddButton.svelte';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, splitDescription, paymentCategoryName, m } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters';
let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -84,7 +85,7 @@
}
async function deletePayment(/** @type {string} */ paymentId) {
if (!await confirm(t('delete_payment_confirm', lang))) {
if (!await confirm(t.delete_payment_confirm)) {
return;
}
@@ -127,18 +128,18 @@
</script>
<svelte:head>
<title>{t('all_payments_title', lang)} - {t('cospend', lang)}</title>
<title>{t.all_payments_title} - {t.cospend}</title>
</svelte:head>
<main class="payments-list">
<div class="header">
<div class="header-content">
<h1 class="sr-only">{t('all_payments_title', lang)}</h1>
<h1 class="sr-only">{t.all_payments_title}</h1>
</div>
</div>
{#if loading && payments.length === 0}
<div class="loading">{t('loading_payments', lang)}</div>
<div class="loading">{t.loading_payments}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if payments.length === 0}
@@ -147,9 +148,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<h2>{t('no_payments_yet', lang)}</h2>
<p>{t('start_first_expense', lang)}</p>
<a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t('add_first_payment', lang)}</a>
<h2>{t.no_payments_yet}</h2>
<p>{t.start_first_expense}</p>
<a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t.add_first_payment}</a>
</div>
</div>
{:else}
@@ -161,7 +162,7 @@
<div class="settlement-header">
<div class="settlement-badge">
<span class="settlement-icon">💸</span>
<span class="settlement-label">{t('settlement', lang)}</span>
<span class="settlement-label">{t.settlement}</span>
</div>
<span class="settlement-date">{formatDate(payment.date)}</span>
</div>
@@ -218,29 +219,29 @@
<div class="payment-details">
<div class="detail-row">
<span class="label">{t('paid_by_label', lang)}</span>
<span class="label">{t.paid_by_label}</span>
<span class="value">{payment.paidBy}</span>
</div>
<div class="detail-row">
<span class="label">{t('split_method_label', lang)}</span>
<span class="label">{t.split_method_label}</span>
<span class="value">{getSplitDescription(payment)}</span>
</div>
</div>
{#if payment.splits && payment.splits.length > 0}
<div class="splits-summary">
<h4>{t('split_details', lang)}</h4>
<h4>{t.split_details}</h4>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item">
<span class="split-user">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0}
{t('owed', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{t.owed} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{/if}
</span>
</div>
@@ -258,14 +259,14 @@
{#if data.currentOffset > 0}
<a href="?offset={Math.max(0, data.currentOffset - data.limit)}&limit={data.limit}"
class="btn btn-secondary">
{t('previous', lang)}
{t.previous}
</a>
{/if}
{#if hasMore}
<a href="?offset={data.currentOffset + data.limit}&limit={data.limit}"
class="btn btn-secondary">
{t('next', lang)}
{t.next}
</a>
{/if}
@@ -273,7 +274,7 @@
{#if hasMore}
<button class="btn btn-secondary js-only" onclick={loadMore} disabled={loading}
style="display: none;">
{loading ? t('loading_ellipsis', lang) : t('load_more', lang)}
{loading ? t.loading_ellipsis : t.load_more}
</button>
{/if}
</div>
@@ -3,7 +3,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, getCategoryOptionsI18n, frequencyDescription, m } from '$lib/js/cospendI18n';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
@@ -17,6 +17,7 @@
let { data, form } = $props();
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -95,29 +96,29 @@
// No-JS fallback text - always generic
if (!jsEnhanced) {
if (predefinedMode) {
return t('paid_in_full', lang);
return t.paid_in_full;
} else {
return t('paid_in_full', lang);
return t.paid_in_full;
}
}
// JavaScript-enhanced reactive text
if (!formData.paidBy) {
return t('paid_in_full', lang);
return t.paid_in_full;
}
// Special handling for 2-user predefined setup
if (predefinedMode && users.length === 2) {
const otherUser = users.find(user => user !== formData.paidBy);
// Always show "for" the other user (who benefits) regardless of who pays
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang);
return otherUser ? `${t.paid_in_full_for} ${otherUser}` : t.paid_in_full;
}
// General case with JS
if (formData.paidBy === data.currentUser) {
return t('paid_in_full_by_you', lang);
return t.paid_in_full_by_you;
} else {
return `${t('paid_in_full_by', lang)} ${formData.paidBy}`;
return `${t.paid_in_full_by} ${formData.paidBy}`;
}
});
@@ -363,44 +364,44 @@
</script>
<svelte:head>
<title>{t('add_payment_title', lang)} - {t('cospend', lang)}</title>
<title>{t.add_payment_title} - {t.cospend}</title>
</svelte:head>
<main class="add-payment">
<div class="header">
<h1 class="sr-only">{t('add_payment_title', lang)}</h1>
<p>{t('add_payment_subtitle', lang)}</p>
<h1 class="sr-only">{t.add_payment_title}</h1>
<p>{t.add_payment_subtitle}</p>
</div>
<form method="POST" use:enhance class="payment-form">
<div class="form-section">
<h2>{t('payment_details_section', lang)}</h2>
<h2>{t.payment_details_section}</h2>
<div class="form-group">
<label for="title">{t('title_label', lang)}</label>
<label for="title">{t.title_label}</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
required
placeholder={t('title_placeholder', lang)}
placeholder={t.title_placeholder}
/>
</div>
<div class="form-group">
<label for="description">{t('description_label', lang)}</label>
<label for="description">{t.description_label}</label>
<textarea
id="description"
name="description"
value={formData.description}
placeholder={t('description_placeholder', lang)}
placeholder={t.description_placeholder}
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="category">{t('category_star', lang)}</label>
<label for="category">{t.category_star}</label>
<select id="category" name="category" value={formData.category} required>
{#each categoryOptions as option}
<option value={option.value}>{option.label}</option>
@@ -410,7 +411,7 @@
<div class="form-row">
<div class="form-group">
<label for="amount">{t('amount_label', lang)}</label>
<label for="amount">{t.amount_label}</label>
<div class="amount-currency">
<input
type="number"
@@ -430,11 +431,11 @@
</div>
{#if formData.currency !== 'CHF'}
<div class="conversion-info">
<small class="help-text">{t('conversion_hint', lang)}</small>
<small class="help-text">{t.conversion_hint}</small>
{#if loadingExchangeRate}
<div class="conversion-preview loading">
<small>🔄 {t('fetching_rate', lang)}</small>
<small>🔄 {t.fetching_rate}</small>
</div>
{:else if exchangeRateError}
<div class="conversion-preview error">
@@ -454,17 +455,17 @@
</div>
<div class="form-group">
<label for="date">{t('payment_date', lang)}</label>
<label for="date">{t.payment_date}</label>
<DatePicker bind:value={formData.date} {lang} />
<input type="hidden" name="date" value={formData.date} />
{#if formData.currency !== 'CHF'}
<small class="help-text">{t('exchange_rate_date', lang)}</small>
<small class="help-text">{t.exchange_rate_date}</small>
{/if}
</div>
</div>
<div class="form-group">
<label for="paidBy">{t('paid_by_form', lang)}</label>
<label for="paidBy">{t.paid_by_form}</label>
<select id="paidBy" name="paidBy" bind:value={formData.paidBy} required>
{#each users as user}
<option value={user}>{user}</option>
@@ -475,7 +476,7 @@
<div class="form-group">
<label class="checkbox-label">
<Toggle bind:checked={formData.isRecurring} />
<span>{t('make_recurring', lang)}</span>
<span>{t.make_recurring}</span>
<input type="hidden" name="isRecurring" value={formData.isRecurring ? 'true' : 'false'} />
</label>
</div>
@@ -483,24 +484,24 @@
{#if formData.isRecurring}
<div class="form-section">
<h2>{t('recurring_section', lang)}</h2>
<h2>{t.recurring_section}</h2>
<div class="recurring-options">
<div class="form-row">
<div class="form-group">
<label for="frequency">{t('frequency_label', lang)}</label>
<label for="frequency">{t.frequency_label}</label>
<select id="frequency" name="recurringFrequency" bind:value={recurringData.frequency} required>
<option value="daily">{t('freq_daily', lang)}</option>
<option value="weekly">{t('freq_weekly', lang)}</option>
<option value="monthly">{t('freq_monthly', lang)}</option>
<option value="quarterly">{t('freq_quarterly', lang)}</option>
<option value="yearly">{t('freq_yearly', lang)}</option>
<option value="custom">{t('freq_custom', lang)}</option>
<option value="daily">{t.freq_daily}</option>
<option value="weekly">{t.freq_weekly}</option>
<option value="monthly">{t.freq_monthly}</option>
<option value="quarterly">{t.freq_quarterly}</option>
<option value="yearly">{t.freq_yearly}</option>
<option value="custom">{t.freq_custom}</option>
</select>
</div>
<div class="form-group">
<label for="recurringStartDate">{t('start_date', lang)}</label>
<label for="recurringStartDate">{t.start_date}</label>
<DatePicker bind:value={recurringData.startDate} {lang} />
<input type="hidden" name="recurringStartDate" value={recurringData.startDate} />
</div>
@@ -535,16 +536,16 @@
{/if}
<div class="form-group">
<label for="recurringEndDate">{t('end_date_optional', lang)}</label>
<label for="recurringEndDate">{t.end_date_optional}</label>
<DatePicker bind:value={recurringData.endDate} min={recurringData.startDate} {lang} />
<input type="hidden" name="recurringEndDate" value={recurringData.endDate} />
<small class="help-text">{t('end_date_hint', lang)}</small>
<small class="help-text">{t.end_date_hint}</small>
</div>
{#if nextExecutionPreview}
<div class="execution-preview">
<h3>{t('next_execution_preview', lang)}</h3>
<h3>{t.next_execution_preview}</h3>
<p class="next-execution">{nextExecutionPreview}</p>
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (recurringData), lang)}</p>
</div>
@@ -609,7 +610,7 @@
<div class="error">{error}</div>
{/if}
<SaveFab disabled={loading} label={t('create_payment', lang)} />
<SaveFab disabled={loading} label={t.create_payment} />
</form>
</main>
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, getCategoryOptionsI18n, m } from '$lib/js/cospendI18n';
import { confirm } from '$lib/js/confirmDialog.svelte';
import FormSection from '$lib/components/FormSection.svelte';
import ImageUpload from '$lib/components/ImageUpload.svelte';
@@ -16,6 +16,7 @@
let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -368,25 +369,25 @@
</script>
<svelte:head>
<title>{t('edit_payment_title', lang)} - {t('cospend', lang)}</title>
<title>{t.edit_payment_title} - {t.cospend}</title>
</svelte:head>
<main class="edit-payment">
<div class="header">
<h1 class="sr-only">{t('edit_payment_title', lang)}</h1>
<p>{t('edit_payment_subtitle', lang)}</p>
<h1 class="sr-only">{t.edit_payment_title}</h1>
<p>{t.edit_payment_subtitle}</p>
</div>
{#if loading}
<div class="loading">{t('loading_payments', lang)}</div>
<div class="loading">{t.loading_payments}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if payment}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
} class="payment-form">
<FormSection title={t('payment_details', lang)}>
<FormSection title={t.payment_details}>
<div class="form-group">
<label for="title">{t('title_label', lang)}</label>
<label for="title">{t.title_label}</label>
<input
type="text"
id="title"
@@ -396,7 +397,7 @@
</div>
<div class="form-group">
<label for="description">{t('description_label', lang)}</label>
<label for="description">{t.description_label}</label>
<textarea
id="description"
bind:value={payment.description}
@@ -405,7 +406,7 @@
</div>
<div class="form-group">
<label for="category">{t('category_star', lang)}</label>
<label for="category">{t.category_star}</label>
<select id="category" bind:value={payment.category} required>
{#each categoryOptions as option}
<option value={option.value}>{option.label}</option>
@@ -415,7 +416,7 @@
<div class="form-row">
<div class="form-group">
<label for="amount">{t('amount_label', lang)}</label>
<label for="amount">{t.amount_label}</label>
<div class="amount-currency">
{#if payment.originalAmount && payment.currency !== 'CHF'}
<!-- Show original amount for foreign currency -->
@@ -478,13 +479,13 @@
</div>
<div class="form-group">
<label for="date">{t('date', lang)}</label>
<label for="date">{t.date}</label>
<DatePicker bind:value={paymentDateStr} {lang} />
</div>
</div>
<div class="form-group">
<label for="paidBy">{t('paid_by_form', lang)}</label>
<label for="paidBy">{t.paid_by_form}</label>
<input
type="text"
id="paidBy"
@@ -507,18 +508,18 @@
/>
{#if payment.splits && payment.splits.length > 0}
<FormSection title={t('split_config', lang)}>
<FormSection title={t.split_config}>
<div class="split-method-info">
<span class="label">{t('split_method_form', lang)}</span>
<span class="label">{t.split_method_form}</span>
<span class="value">
{#if payment.splitMethod === 'equal'}
{t('equal_split', lang)}
{t.equal_split}
{:else if payment.splitMethod === 'full'}
{t('paid_in_full', lang)}
{t.paid_in_full}
{:else if payment.splitMethod === 'personal_equal'}
{t('personal_equal_split', lang)}
{t.personal_equal_split}
{:else if payment.splitMethod === 'proportional'}
{t('custom_proportions', lang)}
{t.custom_proportions}
{:else}
{payment.splitMethod}
{/if}
@@ -527,8 +528,8 @@
{#if payment.splitMethod === 'personal_equal'}
<div class="personal-amounts-editor">
<h3>{t('personal_amounts', lang)}</h3>
<p class="description">{t('personal_amounts_desc', lang)}</p>
<h3>{t.personal_amounts}</h3>
<p class="description">{t.personal_amounts_desc}</p>
{#each payment.splits as split, index}
<div class="personal-input">
<label for="personal_{split.username}">{split.username}</label>
@@ -551,10 +552,10 @@
{@const remainder = Math.max(0, Number(payment.amount) - totalPersonal)}
{@const hasError = totalPersonal > Number(payment.amount)}
<div class="remainder-info" class:error={hasError}>
<span>{t('total_personal', lang)}: CHF {totalPersonal.toFixed(2)}</span>
<span>{t('remainder_to_split', lang)}: CHF {remainder.toFixed(2)}</span>
<span>{t.total_personal}: CHF {totalPersonal.toFixed(2)}</span>
<span>{t.remainder_to_split}: CHF {remainder.toFixed(2)}</span>
{#if hasError}
<div class="error-message">⚠️ {t('personal_exceeds', lang)}</div>
<div class="error-message">⚠️ {t.personal_exceeds}</div>
{/if}
</div>
{/if}
@@ -562,17 +563,17 @@
{/if}
<div class="splits-display">
<h3>{t('split_preview', lang)}</h3>
<h3>{t.split_preview}</h3>
{#each payment.splits as split}
<div class="split-item">
<span class="split-username">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
{t('owes', lang)} CHF {split.amount.toFixed(2)}
{t.owes} CHF {split.amount.toFixed(2)}
{:else if split.amount < 0}
{t('owed', lang)} CHF {Math.abs(split.amount).toFixed(2)}
{t.owed} CHF {Math.abs(split.amount).toFixed(2)}
{:else}
{t('owes', lang)} CHF {split.amount.toFixed(2)}
{t.owes} CHF {split.amount.toFixed(2)}
{/if}
</span>
</div>
@@ -592,11 +593,11 @@
onclick={deletePayment}
disabled={deleting || saving}
>
{deleting ? t('deleting', lang) : t('delete_payment', lang)}
{deleting ? t.deleting : t.delete_payment}
</button>
</div>
<SaveFab disabled={saving || deleting} label={t('save_changes', lang)} />
<SaveFab disabled={saving || deleting} label={t.save_changes} />
</form>
{/if}
</main>
@@ -6,7 +6,7 @@
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { getCategoryEmoji } from '$lib/utils/categories';
import EditButton from '$lib/components/EditButton.svelte';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, splitDescription, paymentCategoryName, m } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters';
@@ -14,6 +14,7 @@
let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -54,13 +55,13 @@
</script>
<svelte:head>
<title>{payment ? payment.title : 'Payment'} - {t('cospend', lang)}</title>
<title>{payment ? payment.title : 'Payment'} - {t.cospend}</title>
</svelte:head>
<main class="payment-view">
{#if loading}
<div class="loading">{t('loading_payments', lang)}</div>
<div class="loading">{t.loading_payments}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if payment}
@@ -75,7 +76,7 @@
{formatAmountWithCurrency(payment)}
{#if payment.currency !== 'CHF' && payment.exchangeRate}
<div class="exchange-rate-info">
<small>{t('exchange_rate', lang)}: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small>
<small>{t.exchange_rate}: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small>
</div>
{/if}
</div>
@@ -90,30 +91,30 @@
<div class="payment-info">
<div class="info-grid">
<div class="info-item">
<span class="label">{t('date', lang)}</span>
<span class="label">{t.date}</span>
<span class="value">{formatDate(payment.date)}</span>
</div>
<div class="info-item">
<span class="label">{t('paid_by_label', lang)}</span>
<span class="label">{t.paid_by_label}</span>
<span class="value">{payment.paidBy}</span>
</div>
<div class="info-item">
<span class="label">{t('created_by', lang)}</span>
<span class="label">{t.created_by}</span>
<span class="value">{payment.createdBy}</span>
</div>
<div class="info-item">
<span class="label">{t('category_label', lang)}</span>
<span class="label">{t.category_label}</span>
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
</div>
<div class="info-item">
<span class="label">{t('split_method_label', lang)}</span>
<span class="label">{t.split_method_label}</span>
<span class="value">{getSplitDescription(payment)}</span>
</div>
</div>
{#if payment.description}
<div class="description">
<h3>{t('description', lang)}</h3>
<h3>{t.description}</h3>
<p>{payment.description}</p>
</div>
{/if}
@@ -121,7 +122,7 @@
{#if payment.splits && payment.splits.length > 0}
<div class="splits-section">
<h3>{t('split_details', lang)}</h3>
<h3>{t.split_details}</h3>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item" class:current-user={split.username === data.session?.user?.nickname}>
@@ -130,17 +131,17 @@
<div class="user-info">
<span class="username">{split.username}</span>
{#if split.username === data.session?.user?.nickname}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
</div>
</div>
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0}
{t('owed', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{t.owed} {formatCurrency(split.amount, 'CHF', loc)}
{:else}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{/if}
</div>
</div>
@@ -9,11 +9,12 @@
import { formatCurrency } from '$lib/utils/formatters';
import Toggle from '$lib/components/Toggle.svelte';
import { page } from '$app/state';
import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName, frequencyDescription, formatNextExecutionI18n } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, paymentCategoryName, frequencyDescription, formatNextExecutionI18n, m } from '$lib/js/cospendI18n';
let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -67,7 +68,7 @@
}
async function deleteRecurringPayment(/** @type {string} */ paymentId, /** @type {string} */ title) {
if (!await confirm(`${t('delete_recurring_confirm', lang)} "${title}"?`)) {
if (!await confirm(`${t.delete_recurring_confirm} "${title}"?`)) {
return;
}
@@ -99,31 +100,31 @@
</script>
<svelte:head>
<title>{t('recurring_title', lang)} - {t('cospend', lang)}</title>
<title>{t.recurring_title} - {t.cospend}</title>
</svelte:head>
<main class="recurring-payments">
<div class="header">
<h1 class="sr-only">{t('recurring_title', lang)}</h1>
<p>{t('recurring_subtitle', lang)}</p>
<h1 class="sr-only">{t.recurring_title}</h1>
<p>{t.recurring_subtitle}</p>
</div>
<div class="filters">
<label>
<Toggle bind:checked={showActiveOnly} />
<span>{t('show_active_only', lang)}</span>
<span>{t.show_active_only}</span>
</label>
</div>
{#if loading}
<div class="loading">{t('loading_recurring', lang)}</div>
<div class="loading">{t.loading_recurring}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if recurringPayments.length === 0}
<div class="empty-state">
<h2>{t('no_recurring', lang)}</h2>
<p>{t('no_recurring_desc', lang)}</p>
<a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t('add_first_payment', lang)}</a>
<h2>{t.no_recurring}</h2>
<p>{t.no_recurring_desc}</p>
<a href={resolve('/[cospendRoot=cospendRoot]/payments/add', { cospendRoot: root })} class="btn btn-primary">{t.add_first_payment}</a>
</div>
{:else}
<div class="payments-grid">
@@ -134,7 +135,7 @@
<span class="category-emoji">{getCategoryEmoji(payment.category)}</span>
<h3>{payment.title}</h3>
<span class="status-badge" class:active={payment.isActive} class:inactive={!payment.isActive}>
{payment.isActive ? t('active', lang) : t('inactive', lang)}
{payment.isActive ? t.active : t.inactive}
</span>
</div>
<div class="payment-amount">
@@ -148,17 +149,17 @@
<div class="payment-details">
<div class="detail-row">
<span class="label">{t('category_label', lang)}</span>
<span class="label">{t.category_label}</span>
<span class="value">{paymentCategoryName(payment.category, lang)}</span>
</div>
<div class="detail-row">
<span class="label">{t('frequency', lang)}</span>
<span class="label">{t.frequency}</span>
<span class="value">{frequencyDescription(payment, lang)}</span>
</div>
<div class="detail-row">
<span class="label">{t('paid_by_label', lang)}</span>
<span class="label">{t.paid_by_label}</span>
<div class="payer-info">
<ProfilePicture username={payment.paidBy} size={20} />
<span class="value">{payment.paidBy}</span>
@@ -166,7 +167,7 @@
</div>
<div class="detail-row">
<span class="label">{t('next_execution', lang)}</span>
<span class="label">{t.next_execution}</span>
<span class="value next-execution">
{formatNextExecutionI18n(new Date(payment.nextExecutionDate), lang)}
</span>
@@ -174,21 +175,21 @@
{#if payment.lastExecutionDate}
<div class="detail-row">
<span class="label">{t('last_executed', lang)}</span>
<span class="label">{t.last_executed}</span>
<span class="value">{formatDate(payment.lastExecutionDate)}</span>
</div>
{/if}
{#if payment.endDate}
<div class="detail-row">
<span class="label">{t('ends', lang)}</span>
<span class="label">{t.ends}</span>
<span class="value">{formatDate(payment.endDate)}</span>
</div>
{/if}
</div>
<div class="splits-preview">
<h4>{t('split_between', lang)}</h4>
<h4>{t.split_between}</h4>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item">
@@ -196,11 +197,11 @@
<span class="username">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0}
{t('gets', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{t.gets} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{t.owes} {formatCurrency(split.amount, 'CHF', loc)}
{/if}
</span>
</div>
@@ -210,7 +211,7 @@
<div class="card-actions">
<a href={resolve('/[cospendRoot=cospendRoot]/recurring/edit/[id]', { cospendRoot: root, id: payment._id })} class="btn btn-secondary btn-small">
{t('edit', lang)}
{t.edit}
</a>
<button
class="btn btn-small"
@@ -218,13 +219,13 @@
class:btn-success={!payment.isActive}
onclick={() => toggleActiveStatus(payment._id, payment.isActive)}
>
{payment.isActive ? t('pause', lang) : t('activate', lang)}
{payment.isActive ? t.pause : t.activate}
</button>
<button
class="btn btn-danger btn-small"
onclick={() => deleteRecurringPayment(payment._id, payment.title)}
>
{t('delete_', lang)}
{t.delete_}
</button>
</div>
</div>
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, getCategoryOptionsI18n, frequencyDescription, m } from '$lib/js/cospendI18n';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
@@ -14,6 +14,7 @@
let { data } = $props();
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -290,47 +291,47 @@
</script>
<svelte:head>
<title>{t('edit_recurring_title', lang)} - {t('cospend', lang)}</title>
<title>{t.edit_recurring_title} - {t.cospend}</title>
</svelte:head>
<main class="edit-recurring-payment">
<div class="header">
<h1 class="sr-only">{t('edit_recurring_title', lang)}</h1>
<h1 class="sr-only">{t.edit_recurring_title}</h1>
</div>
{#if loadingPayment}
<div class="loading">{t('loading_recurring', lang)}</div>
<div class="loading">{t.loading_recurring}</div>
{:else if error && !formData.title}
<div class="error">Error: {error}</div>
{:else}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
} class="payment-form">
<div class="form-section">
<h2>{t('payment_details_section', lang)}</h2>
<h2>{t.payment_details_section}</h2>
<div class="form-group">
<label for="title">{t('title_label', lang)}</label>
<label for="title">{t.title_label}</label>
<input
type="text"
id="title"
bind:value={formData.title}
required
placeholder={t('title_placeholder', lang)}
placeholder={t.title_placeholder}
/>
</div>
<div class="form-group">
<label for="description">{t('description_label', lang)}</label>
<label for="description">{t.description_label}</label>
<textarea
id="description"
bind:value={formData.description}
placeholder={t('description_placeholder', lang)}
placeholder={t.description_placeholder}
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="category">{t('category_star', lang)}</label>
<label for="category">{t.category_star}</label>
<select id="category" bind:value={formData.category} required>
{#each categoryOptions as option}
<option value={option.value}>{option.label}</option>
@@ -340,7 +341,7 @@
<div class="form-row">
<div class="form-group">
<label for="amount">{t('amount_label', lang)}</label>
<label for="amount">{t.amount_label}</label>
<div class="amount-currency">
<input
type="number"
@@ -383,7 +384,7 @@
</div>
<div class="form-group">
<label for="paidBy">{t('paid_by_form', lang)}</label>
<label for="paidBy">{t.paid_by_form}</label>
<select id="paidBy" bind:value={formData.paidBy} required>
{#each users as user}
<option value={user}>{user}</option>
@@ -393,30 +394,30 @@
</div>
<div class="form-group">
<label for="isActive">{t('status_label', lang)}</label>
<label for="isActive">{t.status_label}</label>
<select id="isActive" bind:value={formData.isActive}>
<option value={true}>{t('active', lang)}</option>
<option value={false}>{t('inactive', lang)}</option>
<option value={true}>{t.active}</option>
<option value={false}>{t.inactive}</option>
</select>
</div>
</div>
<div class="form-section">
<h2>{t('recurring_schedule', lang)}</h2>
<h2>{t.recurring_schedule}</h2>
<div class="form-row">
<div class="form-group">
<label for="frequency">{t('frequency_label', lang)}</label>
<label for="frequency">{t.frequency_label}</label>
<select id="frequency" bind:value={formData.frequency} required>
<option value="daily">{t('freq_daily', lang)}</option>
<option value="weekly">{t('freq_weekly', lang)}</option>
<option value="monthly">{t('freq_monthly', lang)}</option>
<option value="custom">{t('freq_custom', lang)}</option>
<option value="daily">{t.freq_daily}</option>
<option value="weekly">{t.freq_weekly}</option>
<option value="monthly">{t.freq_monthly}</option>
<option value="custom">{t.freq_custom}</option>
</select>
</div>
<div class="form-group">
<label for="startDate">{t('start_date', lang)}</label>
<label for="startDate">{t.start_date}</label>
<DatePicker bind:value={formData.startDate} {lang} />
</div>
</div>
@@ -449,14 +450,14 @@
{/if}
<div class="form-group">
<label for="endDate">{t('end_date_optional', lang)}</label>
<label for="endDate">{t.end_date_optional}</label>
<DatePicker bind:value={formData.endDate} {lang} />
<div class="help-text">{t('end_date_hint', lang)}</div>
<div class="help-text">{t.end_date_hint}</div>
</div>
{#if nextExecutionPreview}
<div class="execution-preview">
<h3>{t('next_execution_preview', lang)}</h3>
<h3>{t.next_execution_preview}</h3>
<p class="next-execution">{nextExecutionPreview}</p>
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (formData), lang)}</p>
</div>
@@ -488,7 +489,7 @@
<div class="error">{error}</div>
{/if}
<SaveFab disabled={loading || cronError} label={t('save_changes', lang)} />
<SaveFab disabled={loading || cronError} label={t.save_changes} />
</form>
{/if}
</main>
@@ -5,13 +5,14 @@
import { page } from '$app/state';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { detectCospendLang, cospendRoot, t, locale } from '$lib/js/cospendI18n';
import { detectCospendLang, cospendRoot, locale, m } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters';
let { data, form } = $props();
const lang = $derived(detectCospendLang(page.url.pathname));
const t = $derived(m[lang]);
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
@@ -44,7 +45,7 @@
from: debtData.whoOwesMe[0].username,
to: data.currentUser,
amount: debtData.whoOwesMe[0].netAmount,
description: `${t('settlement_payment', lang)}: ${debtData.whoOwesMe[0].username} → ${data.currentUser}`
description: `${t.settlement_payment}: ${debtData.whoOwesMe[0].username} → ${data.currentUser}`
};
if (!settlementAmount) {
settlementAmount = debtData.whoOwesMe[0].netAmount.toString();
@@ -55,7 +56,7 @@
from: data.currentUser,
to: debtData.whoIOwe[0].username,
amount: debtData.whoIOwe[0].netAmount,
description: `${t('settlement_payment', lang)}: ${data.currentUser} → ${debtData.whoIOwe[0].username}`
description: `${t.settlement_payment}: ${data.currentUser} → ${debtData.whoIOwe[0].username}`
};
if (!settlementAmount) {
settlementAmount = debtData.whoIOwe[0].netAmount.toString();
@@ -73,7 +74,7 @@
from: user,
to: currentUser,
amount: amount,
description: `${t('settlement_payment', lang)}: ${user} → ${currentUser}`
description: `${t.settlement_payment}: ${user} → ${currentUser}`
};
} else {
selectedSettlement = {
@@ -81,7 +82,7 @@
from: currentUser,
to: user,
amount: amount,
description: `${t('settlement_payment', lang)}: ${currentUser} → ${user}`
description: `${t.settlement_payment}: ${currentUser} → ${user}`
};
}
settlementAmount = amount.toString();
@@ -89,13 +90,13 @@
async function processSettlement() {
if (!selectedSettlement || !settlementAmount) {
error = t('error_select_settlement', lang);
error = t.error_select_settlement;
return;
}
const amount = parseFloat(/** @type {string} */ (settlementAmount));
if (isNaN(amount) || amount <= 0) {
error = t('error_valid_amount', lang);
error = t.error_valid_amount;
return;
}
@@ -148,36 +149,36 @@
</script>
<svelte:head>
<title>{t('settle_title', lang)} - {t('cospend', lang)}</title>
<title>{t.settle_title} - {t.cospend}</title>
</svelte:head>
<main class="settle-main">
<div class="header-section">
<h1 class="sr-only">{t('settle_title', lang)}</h1>
<p>{t('settle_subtitle', lang)}</p>
<h1 class="sr-only">{t.settle_title}</h1>
<p>{t.settle_subtitle}</p>
</div>
{#if loading}
<div class="loading">{t('loading_debts', lang)}</div>
<div class="loading">{t.loading_debts}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0}
<div class="no-debts">
<h2>🎉 {t('all_settled', lang)}</h2>
<p>{t('no_debts_msg', lang)}</p>
<h2>🎉 {t.all_settled}</h2>
<p>{t.no_debts_msg}</p>
<div class="actions">
<a href={resolve('/[cospendRoot=cospendRoot]/dash', { cospendRoot: root })} class="btn btn-primary">{t('back_to_dashboard', lang)}</a>
<a href={resolve('/[cospendRoot=cospendRoot]/dash', { cospendRoot: root })} class="btn btn-primary">{t.back_to_dashboard}</a>
</div>
</div>
{:else}
<div class="settlement-container">
<!-- Available Settlements -->
<div class="available-settlements">
<h2>{t('available_settlements', lang)}</h2>
<h2>{t.available_settlements}</h2>
{#if debtData.whoOwesMe.length > 0}
<div class="settlement-section">
<h3>{t('money_owed_to_you', lang)}</h3>
<h3>{t.money_owed_to_you}</h3>
{#each debtData.whoOwesMe as debt}
<div class="settlement-option"
role="button"
@@ -190,11 +191,11 @@
<ProfilePicture username={debt.username} size={40} />
<div class="user-details">
<span class="username">{debt.username}</span>
<span class="debt-amount">{t('owes_you', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
<span class="debt-amount">{t.owes_you} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
</div>
</div>
<div class="settlement-action">
<span class="action-text">{t('receive_payment', lang)}</span>
<span class="action-text">{t.receive_payment}</span>
</div>
</div>
{/each}
@@ -203,7 +204,7 @@
{#if debtData.whoIOwe.length > 0}
<div class="settlement-section">
<h3>{t('money_you_owe', lang)}</h3>
<h3>{t.money_you_owe}</h3>
{#each debtData.whoIOwe as debt}
<div class="settlement-option"
role="button"
@@ -216,11 +217,11 @@
<ProfilePicture username={debt.username} size={40} />
<div class="user-details">
<span class="username">{debt.username}</span>
<span class="debt-amount">{t('you_owe', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
<span class="debt-amount">{t.you_owe} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
</div>
</div>
<div class="settlement-action">
<span class="action-text">{t('make_payment', lang)}</span>
<span class="action-text">{t.make_payment}</span>
</div>
</div>
{/each}
@@ -231,7 +232,7 @@
<!-- Settlement Details -->
{#if selectedSettlement}
<div class="settlement-details">
<h2>{t('settlement_details', lang)}</h2>
<h2>{t.settlement_details}</h2>
<div class="settlement-summary">
<div class="settlement-flow">
@@ -239,7 +240,7 @@
<ProfilePicture username={selectedSettlement.from} size={48} />
<span class="username">{selectedSettlement.from}</span>
{#if selectedSettlement.from === data.currentUser}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
</div>
<div class="flow-arrow"></div>
@@ -247,13 +248,13 @@
<ProfilePicture username={selectedSettlement.to} size={48} />
<span class="username">{selectedSettlement.to}</span>
{#if selectedSettlement.to === data.currentUser}
<span class="you-badge">{t('you', lang)}</span>
<span class="you-badge">{t.you}</span>
{/if}
</div>
</div>
<div class="settlement-amount-section">
<label for="amount">{t('settlement_amount', lang)}</label>
<label for="amount">{t.settlement_amount}</label>
<div class="amount-input">
<span class="currency">CHF</span>
<input
@@ -280,60 +281,60 @@
onclick={processSettlement}
disabled={submitting || !settlementAmount}>
{#if submitting}
{t('recording_settlement', lang)}
{t.recording_settlement}
{:else}
{t('record_settlement', lang)}
{t.record_settlement}
{/if}
</button>
<button class="btn btn-secondary" onclick={() => selectedSettlement = null}>
{t('cancel', lang)}
{t.cancel}
</button>
</div>
</div>
{:else}
<!-- No-JS Fallback Form -->
<div class="settlement-details no-js-fallback">
<h2>{t('record_settlement', lang)}</h2>
<h2>{t.record_settlement}</h2>
<form method="POST" action="?/settle" class="settlement-form">
<div class="form-group">
<label for="settlementType">{t('settlement_type', lang)}</label>
<label for="settlementType">{t.settlement_type}</label>
<select id="settlementType" name="settlementType" required>
<option value="">{t('select_settlement', lang)}</option>
<option value="">{t.select_settlement}</option>
{#each debtData.whoOwesMe as debt}
<option value="receive" data-from="{debt.username}" data-to="{data.currentUser}">
{t('receive_from', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('from', lang)} {debt.username}
{t.receive_from} {formatCurrency(debt.netAmount, 'CHF', loc)} {t.from} {debt.username}
</option>
{/each}
{#each debtData.whoIOwe as debt}
<option value="pay" data-from="{data.currentUser}" data-to="{debt.username}">
{t('pay_to', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('to', lang)} {debt.username}
{t.pay_to} {formatCurrency(debt.netAmount, 'CHF', loc)} {t.to} {debt.username}
</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="fromUser">{t('from_user', lang)}</label>
<label for="fromUser">{t.from_user}</label>
<select id="fromUser" name="fromUser" required>
<option value="">{t('select_payer', lang)}</option>
<option value="">{t.select_payer}</option>
{#each [...debtData.whoOwesMe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option>
<option value="{user}">{user}{user === data.currentUser ? ` (${t.you})` : ''}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="toUser">{t('to_user', lang)}</label>
<label for="toUser">{t.to_user}</label>
<select id="toUser" name="toUser" required>
<option value="">{t('select_recipient', lang)}</option>
<option value="">{t.select_recipient}</option>
{#each [...debtData.whoIOwe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option>
<option value="{user}">{user}{user === data.currentUser ? ` (${t.you})` : ''}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="fallback-amount">{t('settlement_amount_chf', lang)}</label>
<label for="fallback-amount">{t.settlement_amount_chf}</label>
<input
id="fallback-amount"
name="amount"
@@ -348,10 +349,10 @@
<div class="settlement-actions">
<button type="submit" class="btn btn-settlement">
{t('record_settlement', lang)}
{t.record_settlement}
</button>
<a href={resolve('/[cospendRoot=cospendRoot]/dash', { cospendRoot: root })} class="btn btn-secondary">
{t('cancel', lang)}
{t.cancel}
</a>
</div>
</form>
+16 -21
View File
@@ -6,19 +6,22 @@ import Header from '$lib/components/Header.svelte'
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { isEastertide } from '$lib/js/easter.svelte';
import { m, prayersSlug as prayersSlugFor, rosarySlug, calendarSlug, apologetikSlug, faithSlugFromLang } from '$lib/js/faithI18n';
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
let { data, children } = $props();
const isEnglish = $derived(data.lang === 'en');
const isLatin = $derived(data.lang === 'la');
const lang = $derived(/** @type {FaithLang} */ (data.lang));
const t = $derived(m[lang]);
const eastertide = isEastertide();
const prayersSlug = $derived(isLatin ? 'orationes' : isEnglish ? 'prayers' : 'gebete');
const prayersSlug = $derived(prayersSlugFor(lang));
const prayersHref = $derived(resolve('/[faithLang=faithLang]/[prayers=prayersLang]', { faithLang: data.faithLang, prayers: prayersSlug }));
const rosaryHref = $derived(resolve('/[faithLang=faithLang]/[rosary=rosaryLang]', { faithLang: data.faithLang, rosary: isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz' }));
const calendarHref = $derived(resolve('/[faithLang=faithLang]/[calendar=calendarLang]', { faithLang: data.faithLang, calendar: isLatin ? 'calendarium' : isEnglish ? 'calendar' : 'kalender' }));
const rosaryHref = $derived(resolve('/[faithLang=faithLang]/[rosary=rosaryLang]', { faithLang: data.faithLang, rosary: rosarySlug(lang) }));
const calendarHref = $derived(resolve('/[faithLang=faithLang]/[calendar=calendarLang]', { faithLang: data.faithLang, calendar: calendarSlug(lang) }));
// Apologetik has no Latin variant — Latin readers fall back to the English route.
const apologetikHref = $derived(
isLatin
? resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: 'faith', apologetikSlug: 'apologetics' })
: resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: data.faithLang, apologetikSlug: isEnglish ? 'apologetics' : 'apologetik' })
lang === 'la'
? resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: faithSlugFromLang('en'), apologetikSlug: apologetikSlug('en') })
: resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: data.faithLang, apologetikSlug: apologetikSlug(lang) })
);
const angelusHref = $derived(resolve('/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]', {
faithLang: data.faithLang,
@@ -27,14 +30,6 @@ const angelusHref = $derived(resolve('/[faithLang=faithLang]/[prayers=prayersLan
}));
const angelusLabel = $derived(eastertide ? 'Regína Cæli' : 'Angelus');
const labels = $derived({
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
rosary: isLatin ? 'Rosarium' : isEnglish ? 'Rosary' : 'Rosenkranz',
catechesis: isEnglish ? 'Catechesis' : 'Katechese',
apologetics: isLatin ? 'Apologetica' : isEnglish ? 'Apologetics' : 'Apologetik',
calendar: isLatin ? 'Calendarium' : isEnglish ? 'Calendar' : 'Kalender'
});
const typedLang = $derived(/** @type {'de' | 'en'} */ (data.lang));
/** @param {string} path */
@@ -51,16 +46,16 @@ const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref));
<Header>
{#snippet links()}
<ul class=site_header>
<li style="--active-fill: var(--nord12)"><a href={prayersHref} class:active={prayersActive} title={labels.prayers}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 640 512" fill="currentColor"><path d="M351.2 4.8c3.2-2 6.6-3.3 10-4.1c4.7-1 9.6-.9 14.1 .1c7.7 1.8 14.8 6.5 19.4 13.6L514.6 194.2c8.8 13.1 13.4 28.6 13.4 44.4v73.5c0 6.9 4.4 13 10.9 15.2l79.2 26.4C631.2 358 640 370.2 640 384v96c0 9.9-4.6 19.3-12.5 25.4s-18.1 8.1-27.7 5.5L431 465.9c-56-14.9-95-65.7-95-123.7V224c0-17.7 14.3-32 32-32s32 14.3 32 32v80c0 8.8 7.2 16 16 16s16-7.2 16-16V219.1c0-7-1.8-13.8-5.3-19.8L340.3 48.1c-1.7-3-2.9-6.1-3.6-9.3c-1-4.7-1-9.6 .1-14.1c1.9-8 6.8-15.2 14.3-19.9zm-62.4 0c7.5 4.6 12.4 11.9 14.3 19.9c1.1 4.6 1.2 9.4 .1 14.1c-.7 3.2-1.9 6.3-3.6 9.3L213.3 199.3c-3.5 6-5.3 12.9-5.3 19.8V304c0 8.8 7.2 16 16 16s16-7.2 16-16V224c0-17.7 14.3-32 32-32s32 14.3 32 32V342.3c0 58-39 108.7-95 123.7l-168.7 45c-9.6 2.6-19.9 .5-27.7-5.5S0 490 0 480V384c0-13.8 8.8-26 21.9-30.4l79.2-26.4c6.5-2.2 10.9-8.3 10.9-15.2V238.5c0-15.8 4.7-31.2 13.4-44.4L245.2 14.5c4.6-7.1 11.7-11.8 19.4-13.6c4.6-1.1 9.4-1.2 14.1-.1c3.5 .8 6.9 2.1 10 4.1z"/></svg><span class="nav-label">{labels.prayers}</span></a></li>
<li style="--active-fill: var(--nord11)"><a href={rosaryHref} class:active={isActive(rosaryHref)} title={labels.rosary}><svg class="nav-icon" width="16" height="16" viewBox="0 0 512 512" fill="currentColor"><path d="M241.251,145.056c-39.203-17.423-91.472,17.423-104.54,60.982c-13.068,43.558,8.712,117.608,65.337,143.742 c56.626,26.135,108.896-8.712,87.117-39.202c-74.049-8.712-121.963-87.117-100.184-126.319S280.453,162.479,241.251,145.056z"/><path d="M337.079,271.375c47.914-39.202,21.779-126.319-17.423-135.031c-39.202-8.712-56.626,13.068-26.135,39.202 c39.203,30.491-8.712,91.472-39.202,87.117C254.318,262.663,289.165,310.577,337.079,271.375z"/><path d="M254.318,119.788c43.558-17.423,74.049-9.579,100.184,16.556c13.068-39.202-30.491-104.54-108.896-113.252 S93.153,118.921,127.999,171.191C136.711,153.767,188.981,106.721,254.318,119.788z"/><path d="M110.576,245.24C36.527,262.663,28.87,335.248,45.239,380.27c17.423,47.914,4.356,82.761,26.135,91.472 c20.622,8.253,91.472,13.068,152.454,17.423c60.982,4.356,108.896-47.914,91.472-108.896 C141.067,410.761,110.576,284.442,110.576,245.24z"/><path d="M93.883,235.796c0,0,2.178-28.313,10.89-43.558c-4.356-4.356-8.712-21.779-8.712-21.779 s-4.356-19.601-4.356-34.846c-32.669-6.534-89.295,34.846-91.472,41.38c-2.178,6.534,10.889,80.583,39.202,82.761 C69.927,235.796,93.883,235.796,93.883,235.796z"/><path d="M489.533,175.546c-39.202-82.761-113.252-65.337-113.252-65.337s4.356,21.779-4.356,34.846 c43.558,47.914,13.067,146.643-24.681,158.265c130.675,56.626,159.712-58.081,164.068-75.504 C515.668,210.393,498.245,197.326,489.533,175.546z"/><path d="M454.108,332.076c-22.359,15.841-85.663,11.613-121.964-7.265c1.446,14.514-13.067,37.756-20.325,39.202 c27.59,11.621,53.725,62.436,7.265,116.161c18.878,18.87,95.828,4.356,140.842-24.689c7.325-4.722,18.869-52.27,21.779-79.851 C485.56,339.103,488.963,307.387,454.108,332.076z"/><path d="M257.227,213.294c-18.928,5.164-30.439-6.27-23.234-18.869c5.811-10.167,5.266-20.69-8.712-13.068 c-29.044,17.423-11.612,66.784,24.689,62.428c49.36-17.423,27.581-62.428,14.514-60.982 C251.417,184.249,273.196,208.938,257.227,213.294z"/></svg><span class="nav-label">{labels.rosary}</span></a></li>
<li style="--active-fill: var(--nord12)"><a href={prayersHref} class:active={prayersActive} title={t.prayers}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 640 512" fill="currentColor"><path d="M351.2 4.8c3.2-2 6.6-3.3 10-4.1c4.7-1 9.6-.9 14.1 .1c7.7 1.8 14.8 6.5 19.4 13.6L514.6 194.2c8.8 13.1 13.4 28.6 13.4 44.4v73.5c0 6.9 4.4 13 10.9 15.2l79.2 26.4C631.2 358 640 370.2 640 384v96c0 9.9-4.6 19.3-12.5 25.4s-18.1 8.1-27.7 5.5L431 465.9c-56-14.9-95-65.7-95-123.7V224c0-17.7 14.3-32 32-32s32 14.3 32 32v80c0 8.8 7.2 16 16 16s16-7.2 16-16V219.1c0-7-1.8-13.8-5.3-19.8L340.3 48.1c-1.7-3-2.9-6.1-3.6-9.3c-1-4.7-1-9.6 .1-14.1c1.9-8 6.8-15.2 14.3-19.9zm-62.4 0c7.5 4.6 12.4 11.9 14.3 19.9c1.1 4.6 1.2 9.4 .1 14.1c-.7 3.2-1.9 6.3-3.6 9.3L213.3 199.3c-3.5 6-5.3 12.9-5.3 19.8V304c0 8.8 7.2 16 16 16s16-7.2 16-16V224c0-17.7 14.3-32 32-32s32 14.3 32 32V342.3c0 58-39 108.7-95 123.7l-168.7 45c-9.6 2.6-19.9 .5-27.7-5.5S0 490 0 480V384c0-13.8 8.8-26 21.9-30.4l79.2-26.4c6.5-2.2 10.9-8.3 10.9-15.2V238.5c0-15.8 4.7-31.2 13.4-44.4L245.2 14.5c4.6-7.1 11.7-11.8 19.4-13.6c4.6-1.1 9.4-1.2 14.1-.1c3.5 .8 6.9 2.1 10 4.1z"/></svg><span class="nav-label">{t.prayers}</span></a></li>
<li style="--active-fill: var(--nord11)"><a href={rosaryHref} class:active={isActive(rosaryHref)} title={t.rosary}><svg class="nav-icon" width="16" height="16" viewBox="0 0 512 512" fill="currentColor"><path d="M241.251,145.056c-39.203-17.423-91.472,17.423-104.54,60.982c-13.068,43.558,8.712,117.608,65.337,143.742 c56.626,26.135,108.896-8.712,87.117-39.202c-74.049-8.712-121.963-87.117-100.184-126.319S280.453,162.479,241.251,145.056z"/><path d="M337.079,271.375c47.914-39.202,21.779-126.319-17.423-135.031c-39.202-8.712-56.626,13.068-26.135,39.202 c39.203,30.491-8.712,91.472-39.202,87.117C254.318,262.663,289.165,310.577,337.079,271.375z"/><path d="M254.318,119.788c43.558-17.423,74.049-9.579,100.184,16.556c13.068-39.202-30.491-104.54-108.896-113.252 S93.153,118.921,127.999,171.191C136.711,153.767,188.981,106.721,254.318,119.788z"/><path d="M110.576,245.24C36.527,262.663,28.87,335.248,45.239,380.27c17.423,47.914,4.356,82.761,26.135,91.472 c20.622,8.253,91.472,13.068,152.454,17.423c60.982,4.356,108.896-47.914,91.472-108.896 C141.067,410.761,110.576,284.442,110.576,245.24z"/><path d="M93.883,235.796c0,0,2.178-28.313,10.89-43.558c-4.356-4.356-8.712-21.779-8.712-21.779 s-4.356-19.601-4.356-34.846c-32.669-6.534-89.295,34.846-91.472,41.38c-2.178,6.534,10.889,80.583,39.202,82.761 C69.927,235.796,93.883,235.796,93.883,235.796z"/><path d="M489.533,175.546c-39.202-82.761-113.252-65.337-113.252-65.337s4.356,21.779-4.356,34.846 c43.558,47.914,13.067,146.643-24.681,158.265c130.675,56.626,159.712-58.081,164.068-75.504 C515.668,210.393,498.245,197.326,489.533,175.546z"/><path d="M454.108,332.076c-22.359,15.841-85.663,11.613-121.964-7.265c1.446,14.514-13.067,37.756-20.325,39.202 c27.59,11.621,53.725,62.436,7.265,116.161c18.878,18.87,95.828,4.356,140.842-24.689c7.325-4.722,18.869-52.27,21.779-79.851 C485.56,339.103,488.963,307.387,454.108,332.076z"/><path d="M257.227,213.294c-18.928,5.164-30.439-6.27-23.234-18.869c5.811-10.167,5.266-20.69-8.712-13.068 c-29.044,17.423-11.612,66.784,24.689,62.428c49.36-17.423,27.581-62.428,14.514-60.982 C251.417,184.249,273.196,208.938,257.227,213.294z"/></svg><span class="nav-label">{t.rosary}</span></a></li>
{#if eastertide}
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="-10 -274 532 548" fill="currentColor"><path d="M256-168c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM6-63l122 199-56 70c-5 7-8 14-8 23 0 19 16 35 36 35h312c20 0 36-16 36-35 0-9-3-16-8-23l-56-70L507-63c3-6 5-13 5-20 0-20-16-37-37-37-7 0-14 2-20 6l-17 12c-13 8-30 6-40-4l-35-35c-7-7-17-11-27-11s-20 4-27 11l-30 30c-13 13-33 13-46 0l-30-30c-7-7-17-11-27-11s-20 4-27 11l-34 34c-11 11-28 13-41 4l-17-11c-6-4-13-6-20-6-20 0-37 17-37 37 0 7 2 14 6 20z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
{:else}
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="6 -274 564 548" fill="currentColor"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
{/if}
<li style="--active-fill: var(--nord13)"><a href={resolve('/[faithLang=faithLang]/katechese', { faithLang: data.faithLang })} class:active={isActive(`/${data.faithLang}/katechese`)} title={labels.catechesis}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M16 12h2"/><path d="M16 8h2"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/><path d="M6 12h2"/><path d="M6 8h2"/></svg><span class="nav-label">{labels.catechesis}</span></a></li>
<li style="--active-fill: var(--nord10)"><a href={apologetikHref} class:active={isActive(apologetikHref)} title={labels.apologetics}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg><span class="nav-label">{labels.apologetics}</span></a></li>
<li style="--active-fill: var(--nord15)"><a href={calendarHref} class:active={isActive(calendarHref)} title={labels.calendar}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg><span class="nav-label">{labels.calendar}</span></a></li>
<li style="--active-fill: var(--nord13)"><a href={resolve('/[faithLang=faithLang]/katechese', { faithLang: data.faithLang })} class:active={isActive(`/${data.faithLang}/katechese`)} title={t.catechesis}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M16 12h2"/><path d="M16 8h2"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/><path d="M6 12h2"/><path d="M6 8h2"/></svg><span class="nav-label">{t.catechesis}</span></a></li>
<li style="--active-fill: var(--nord10)"><a href={apologetikHref} class:active={isActive(apologetikHref)} title={t.apologetics}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg><span class="nav-label">{t.apologetics}</span></a></li>
<li style="--active-fill: var(--nord15)"><a href={calendarHref} class:active={isActive(calendarHref)} title={t.calendar}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg><span class="nav-label">{t.calendar}</span></a></li>
</ul>
{/snippet}
+21 -31
View File
@@ -2,37 +2,27 @@
import { resolve } from '$app/paths';
import LinksGrid from '$lib/components/LinksGrid.svelte';
import { isEastertide } from '$lib/js/easter.svelte';
import { m, prayersSlug as prayersSlugFor, rosarySlug, calendarSlug, apologetikSlug, faithSlugFromLang } from '$lib/js/faithI18n';
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
let { data } = $props();
const isEnglish = $derived(data.lang === 'en');
const isLatin = $derived(data.lang === 'la');
const prayersPath = $derived(isLatin ? 'orationes' : isEnglish ? 'prayers' : 'gebete');
const rosaryPath = $derived(isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz');
const calendarPath = $derived(isLatin ? 'calendarium' : isEnglish ? 'calendar' : 'kalender');
const lang = $derived(/** @type {FaithLang} */ (data.lang));
const t = $derived(m[lang]);
const prayersPath = $derived(prayersSlugFor(lang));
const rosaryPath = $derived(rosarySlug(lang));
const calendarPath = $derived(calendarSlug(lang));
// Apologetik has no Latin variant — Latin readers fall back to English.
const apologetikHref = $derived(
isLatin
? resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: 'faith', apologetikSlug: 'apologetics' })
: resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: data.faithLang, apologetikSlug: isEnglish ? 'apologetics' : 'apologetik' })
lang === 'la'
? resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: faithSlugFromLang('en'), apologetikSlug: apologetikSlug('en') })
: resolve('/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]', { faithLang: data.faithLang, apologetikSlug: apologetikSlug(lang) })
);
const eastertide = isEastertide();
const labels = $derived({
title: isLatin ? 'Fides' : isEnglish ? 'Faith' : 'Glaube',
description: isLatin
? 'Hic invenies orationes et rosarium interactivum fidei catholicae.'
: isEnglish
? 'Here you will find some prayers and an interactive rosary for the Catholic faith. A focus on Latin and the older rite will be noticeable.'
: 'Hier findet man einige Gebete und einen interaktiven Rosenkranz zum katholischen Glauben. Ein Fokus auf Latein und den alten Ritus wird zu bemerken sein.',
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
rosary: isLatin ? 'Rosarium Vivum' : isEnglish ? 'Rosary' : 'Rosenkranz',
apologetics: isLatin ? 'Apologetica' : isEnglish ? 'Apologetics' : 'Apologetik',
calendar: isLatin ? 'Calendarium' : isEnglish ? 'Calendar' : 'Kalender'
});
</script>
<svelte:head>
<title>{labels.title} - Bocken</title>
<meta name="description" content={labels.description} />
<title>{t.title} - Bocken</title>
<meta name="description" content={t.description} />
</svelte:head>
<style>
h1{
@@ -80,15 +70,15 @@
</style>
<h1>{labels.title}</h1>
<h1>{t.title}</h1>
<p>
{labels.description}
{t.description}
</p>
<LinksGrid>
<a href={resolve('/[faithLang=faithLang]/[prayers=prayersLang]', { faithLang: data.faithLang, prayers: prayersPath })}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M351.2 4.8c3.2-2 6.6-3.3 10-4.1c4.7-1 9.6-.9 14.1 .1c7.7 1.8 14.8 6.5 19.4 13.6L514.6 194.2c8.8 13.1 13.4 28.6 13.4 44.4v73.5c0 6.9 4.4 13 10.9 15.2l79.2 26.4C631.2 358 640 370.2 640 384v96c0 9.9-4.6 19.3-12.5 25.4s-18.1 8.1-27.7 5.5L431 465.9c-56-14.9-95-65.7-95-123.7V224c0-17.7 14.3-32 32-32s32 14.3 32 32v80c0 8.8 7.2 16 16 16s16-7.2 16-16V219.1c0-7-1.8-13.8-5.3-19.8L340.3 48.1c-1.7-3-2.9-6.1-3.6-9.3c-1-4.7-1-9.6 .1-14.1c1.9-8 6.8-15.2 14.3-19.9zm-62.4 0c7.5 4.6 12.4 11.9 14.3 19.9c1.1 4.6 1.2 9.4 .1 14.1c-.7 3.2-1.9 6.3-3.6 9.3L213.3 199.3c-3.5 6-5.3 12.9-5.3 19.8V304c0 8.8 7.2 16 16 16s16-7.2 16-16V224c0-17.7 14.3-32 32-32s32 14.3 32 32V342.3c0 58-39 108.7-95 123.7l-168.7 45c-9.6 2.6-19.9 .5-27.7-5.5S0 490 0 480V384c0-13.8 8.8-26 21.9-30.4l79.2-26.4c6.5-2.2 10.9-8.3 10.9-15.2V238.5c0-15.8 4.7-31.2 13.4-44.4L245.2 14.5c4.6-7.1 11.7-11.8 19.4-13.6c4.6-1.1 9.4-1.2 14.1-.1c3.5 .8 6.9 2.1 10 4.1z"/></svg>
<h3>{labels.prayers}</h3>
<h3>{t.prayers}</h3>
</a>
<a href={resolve('/[faithLang=faithLang]/[rosary=rosaryLang]', { faithLang: data.faithLang, rosary: rosaryPath })}>
<svg viewBox="0 0 512 512">
@@ -116,11 +106,11 @@
C251.417,184.249,273.196,208.938,257.227,213.294z"/>
</g>
</svg>
<h3>{labels.rosary}</h3>
<h3>{t.rosary}</h3>
</a>
{#if eastertide}
<a href={resolve('/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]', { faithLang: data.faithLang, prayers: prayersPath, prayer: 'regina-caeli' })} class="regina-link">
<span class="easter-badge">{isLatin ? 'Tempore' : isEnglish ? 'In season' : 'Zur Zeit'}</span>
<span class="easter-badge">{t.in_season}</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -274 532 548"><path d="M256-168c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM6-63l122 199-56 70c-5 7-8 14-8 23 0 19 16 35 36 35h312c20 0 36-16 36-35 0-9-3-16-8-23l-56-70L507-63c3-6 5-13 5-20 0-20-16-37-37-37-7 0-14 2-20 6l-17 12c-13 8-30 6-40-4l-35-35c-7-7-17-11-27-11s-20 4-27 11l-30 30c-13 13-33 13-46 0l-30-30c-7-7-17-11-27-11s-20 4-27 11l-34 34c-11 11-28 13-41 4l-17-11c-6-4-13-6-20-6-20 0-37 17-37 37 0 7 2 14 6 20z"/></svg>
<h3>Regína Cæli</h3>
</a>
@@ -131,16 +121,16 @@
</a>
{/if}
<a href={resolve('/[faithLang=faithLang]/katechese', { faithLang: data.faithLang })} class="katechese-link">
{#if isEnglish || isLatin}<span class="lang-badge">DE</span>{/if}
{#if lang !== 'de'}<span class="lang-badge">DE</span>{/if}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -226 532 506"><path d="M256-107v310l1-1c54-22 113-34 172-34h19v-320h-19c-42 0-84 8-123 25-17 7-34 14-50 20zm-25-79 25 10 25-10c47-20 97-30 148-30h35c27 0 48 22 48 48v352c0 27-21 48-48 48h-35c-51 0-101 10-148 30l-13 5c-8 3-16 3-24 0l-13-5c-47-20-97-30-148-30H48c-26 0-48-21-48-48v-352c0-26 22-48 48-48h35c51 0 101 10 148 30z"/></svg>
<h3>Katechese</h3>
</a>
<a href={apologetikHref}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M384 32H512c17.7 0 32 14.3 32 32s-14.3 32-32 32H398.4c-5.2 25.8-22.9 47.1-46.4 57.3V448H544c17.7 0 32 14.3 32 32s-14.3 32-32 32H320 96c-17.7 0-32-14.3-32-32s14.3-32 32-32H288V153.3c-23.5-10.3-41.2-31.6-46.4-57.3H128c-17.7 0-32-14.3-32-32s14.3-32 32-32H256c14.6-19.4 37.8-32 64-32s49.4 12.6 64 32zm55.6 288H584.4L512 195.8 439.6 320zM512 416c-62.9 0-115.2-34-126-78.9c-2.6-11 1-22.3 6.7-32.1l95.2-163.2c5-8.6 14.2-13.8 24.1-13.8s19.1 5.3 24.1 13.8l95.2 163.2c5.7 9.8 9.3 21.1 6.7 32.1C627.2 382 574.9 416 512 416zM126.8 195.8L54.4 320H199.3L126.8 195.8zM.9 337.1c-2.6-11 1-22.3 6.7-32.1l95.2-163.2c5-8.6 14.2-13.8 24.1-13.8s19.1 5.3 24.1 13.8l95.2 163.2c5.7 9.8 9.3 21.1 6.7 32.1C242 382 189.7 416 126.8 416S11.7 382 .9 337.1z"/></svg>
<h3>{labels.apologetics}</h3>
<h3>{t.apologetics}</h3>
</a>
<a href={resolve('/[faithLang=faithLang]/[calendar=calendarLang]', { faithLang: data.faithLang, calendar: calendarPath })}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zm64 80v32c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16V272c0-8.8-7.2-16-16-16H80c-8.8 0-16 7.2-16 16zm128 0v32c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16V272c0-8.8-7.2-16-16-16H208c-8.8 0-16 7.2-16 16zm144-16c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16V272c0-8.8-7.2-16-16-16H336zM64 400v32c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16V400c0-8.8-7.2-16-16-16H80c-8.8 0-16 7.2-16 16zm144-16c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16V400c0-8.8-7.2-16-16-16H208zm112 16v32c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16V400c0-8.8-7.2-16-16-16H336c-8.8 0-16 7.2-16 16z"/></svg>
<h3>{labels.calendar}</h3>
<h3>{t.calendar}</h3>
</a>
</LinksGrid>
@@ -4,11 +4,15 @@
import CaseTabs from '$lib/components/faith/CaseTabs.svelte';
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
import { m, type FaithLang } from '$lib/js/faithI18n';
let { data } = $props();
const faithLang = $derived(data?.faithLang ?? 'faith');
const slug = $derived(faithLang === 'faith' ? 'apologetics' : 'apologetik');
const isLatin = $derived(data?.lang === 'la');
const isGerman = $derived(data?.lang === 'de');
const lang = $derived((data?.lang ?? 'en') as FaithLang);
const t = $derived(m[lang]);
const isLatin = $derived(lang === 'la');
const isGerman = $derived(lang === 'de');
const ARCHETYPES = $derived(data.archetypes);
const ARGUMENTS = $derived(data.args);
@@ -79,18 +83,10 @@
? 'Dreiundzwanzig Einwände, wie sie ein Atheist erheben mag, je in mehreren Stimmen beantwortet.'
: 'Twenty-three objections an atheist might raise, each answered in several voices.'
);
const tocLabel = $derived(
isLatin ? 'Obiectiones' : isGerman ? 'Einwände' : 'Objections'
);
const legendTitle = $derived(
isLatin ? 'Voces respondentes' : isGerman ? 'Die antwortenden Stimmen' : 'The voices that answer'
);
const objectionLabel = $derived(
isLatin ? 'OBIECTIO' : isGerman ? 'EINWAND' : 'OBJECTION'
);
const answeredByLabel = $derived(
isLatin ? 'Respondetur a' : isGerman ? 'Beantwortet von' : 'Answered by'
);
const tocLabel = $derived(t.objections);
const legendTitle = $derived(t.voices_answering);
const objectionLabel = $derived(t.objection_label);
const answeredByLabel = $derived(t.answered_by);
const filterLabels = $derived(
isLatin
? { filteringBy: 'Filtrum:', showAll: 'omnia ostendere' }
@@ -2,21 +2,21 @@
import { resolve } from '$app/paths';
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
import { m, type FaithLang } from '$lib/js/faithI18n';
let { data } = $props();
const faithLang = $derived(data?.faithLang ?? 'faith');
const slug = $derived(faithLang === 'faith' ? 'apologetics' : 'apologetik');
const isLatin = $derived(data?.lang === 'la');
const isGerman = $derived(data?.lang === 'de');
const lang = $derived((data?.lang ?? 'en') as FaithLang);
const t = $derived(m[lang]);
const isLatin = $derived(lang === 'la');
const isGerman = $derived(lang === 'de');
const arg = $derived(data.argument);
const ARCHETYPES = $derived(data.archetypes);
const alexPicks = $derived<string[]>(data.alexPicks ?? []);
const alexPickLabel = $derived(
isLatin ? 'Alexandri delectus' : isGerman ? "Alex' Wahl" : "Alex's pick"
);
const alexPickLabel = $derived(t.alex_pick);
const tocLabel = $derived(
isLatin ? 'Obiectiones' : isGerman ? 'Einwände' : 'Objections'
);
const tocLabel = $derived(t.objections);
const tocItems = $derived(
data.args.map((a) => ({
id: a.id,
@@ -88,7 +88,7 @@
</script>
<svelte:head>
<title>{arg.title} · {isLatin ? 'Apologia' : isGerman ? 'Apologetik' : 'Arguments'} · bocken.org</title>
<title>{arg.title} · {t.arguments_title} · bocken.org</title>
<meta name="description" content={arg.steel} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
@@ -5,11 +5,15 @@
import CaseTabs from '$lib/components/faith/CaseTabs.svelte';
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
import { m, type FaithLang } from '$lib/js/faithI18n';
let { data } = $props();
const faithLang = $derived(data?.faithLang ?? 'faith');
const slug = $derived(faithLang === 'faith' ? 'apologetics' : 'apologetik');
const isLatin = $derived(data?.lang === 'la');
const isGerman = $derived(data?.lang === 'de');
const lang = $derived((data?.lang ?? 'en') as FaithLang);
const t = $derived(m[lang]);
const isLatin = $derived(lang === 'la');
const isGerman = $derived(lang === 'de');
const POS_VOICES = $derived(data.voices);
const POS_LAYERS = $derived(data.layers);
@@ -129,7 +133,7 @@
})
);
const tocLabel = $derived(
isLatin ? 'Argumenta' : isGerman ? 'Belege' : 'Evidences'
t.evidences
);
</script>
@@ -2,11 +2,15 @@
import { resolve } from '$app/paths';
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
import { m, type FaithLang } from '$lib/js/faithI18n';
let { data } = $props();
const faithLang = $derived(data?.faithLang ?? 'faith');
const slug = $derived(faithLang === 'faith' ? 'apologetics' : 'apologetik');
const isLatin = $derived(data?.lang === 'la');
const isGerman = $derived(data?.lang === 'de');
const lang = $derived((data?.lang ?? 'en') as FaithLang);
const t = $derived(m[lang]);
const isLatin = $derived(lang === 'la');
const isGerman = $derived(lang === 'de');
const arg = $derived(data.argument);
const POS_VOICES = $derived(data.voices);
const POS_LAYERS = $derived(data.layers);
@@ -20,9 +24,7 @@
? { natural: 'Übernatürlich', theism: 'Theismus', christianity: 'Christentum' }
: { natural: 'Supernatural', theism: 'Theism', christianity: 'Christianity' }
);
const tocLabel = $derived(
isLatin ? 'Argumenta' : isGerman ? 'Belege' : 'Evidences'
);
const tocLabel = $derived(t.evidences);
const tocItems = $derived(
POS_ARGUMENTS.map((a) => ({
id: a.id,
@@ -94,7 +96,7 @@
</script>
<svelte:head>
<title>{arg.title} · {isLatin ? 'Argumenta pro' : isGerman ? 'Positives' : 'Positive case'} · bocken.org</title>
<title>{arg.title} · {t.positive_case} · bocken.org</title>
<meta name="description" content={arg.claim} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
@@ -1,14 +1,6 @@
<script lang="ts">
import type { CalendarDay } from '$lib/calendarTypes';
import {
formatLongDate,
rankEmphasis,
humanizePsalterWeek,
humanizeSundayCycle,
t,
t1962,
type CalendarLang
} from './calendarI18n';
import { formatLongDate, rankEmphasis, humanizePsalterWeek, humanizeSundayCycle, type CalendarLang, m, m1962 } from './calendarI18n';
import { litBg, litInk } from './calendarColors';
let {
@@ -22,6 +14,8 @@
todayIso: string;
href?: string;
} = $props();
const t1962 = $derived(m1962[lang]);
const t = $derived(m[lang]);
const color = $derived(day.colorKeys[0] ?? 'GREEN');
const isToday = $derived(day.iso === todayIso);
@@ -42,7 +36,7 @@
>
<span class="hc-rank" aria-hidden="true">{rankNum}</span>
<div class="hc-date">
{#if isToday}{t('today', lang)} · {/if}{formatLongDate(day.iso, lang)}
{#if isToday}{t.today} · {/if}{formatLongDate(day.iso, lang)}
</div>
<h2 class="hc-name">{day.name}</h2>
<div class="hc-tags">
@@ -56,17 +50,17 @@
<span class="hc-tag">{firstOr(day.seasonNames)}</span>
{/if}
{#if day.psalterWeek}
<span class="hc-tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(day.psalterWeek, lang)}</span>
<span class="hc-tag">{t.psalterWeek}: {humanizePsalterWeek(day.psalterWeek, lang)}</span>
{/if}
{#if day.sundayCycle}
<span class="hc-tag">{t('cycle', lang)}: {humanizeSundayCycle(day.sundayCycle)}</span>
<span class="hc-tag">{t.cycle}: {humanizeSundayCycle(day.sundayCycle)}</span>
{/if}
</div>
{#if day.rite1962 && day.rite1962.commemorations.length}
<div class="hc-commems">
<div class="hc-commems-head">
<svg class="hc-commems-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<span class="hc-commems-title">{t1962('commemorations', lang)}</span>
<span class="hc-commems-title">{t1962.commemorations}</span>
</div>
<div class="hc-commems-list">
{#each day.rite1962.commemorations as c (c.id)}
@@ -79,7 +73,7 @@
<div class="hc-stations">
<svg class="hc-stations-label" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 9h4"/><path d="M12 7v5"/><path d="M14 22v-4a2 2 0 0 0-4 0v4"/><path d="M18 22V5l-6-3-6 3v17"/><path d="M4 10.5V22"/><path d="M20 10.5V22"/><path d="M22 22H2"/></svg>
<span class="hc-stations-text">
<span class="hc-stations-title">{t1962('stationChurch', lang)}:</span>
<span class="hc-stations-title">{t1962.stationChurch}:</span>
{#each day.rite1962.stationChurches as s, i (s.key + (s.mass ?? ''))}
{#if i > 0}<span class="hc-stations-sep"> · </span>{/if}<span class="hc-station-name">{s.name}</span>{#if s.mass}<span class="hc-station-mass"> ({s.mass.replace(/_/g, ' ')})</span>{/if}
{/each}
@@ -4,18 +4,7 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import {
getMonthName,
getWeekdayShort,
rankEmphasis,
t,
dioceseLabel,
DIOCESES_1962,
DIOCESES_1969,
DEFAULT_DIOCESE_1962,
DEFAULT_DIOCESE_1969,
type CalendarLang
} from '../../../../calendarI18n';
import { getMonthName, getWeekdayShort, rankEmphasis, dioceseLabel, DIOCESES_1962, DIOCESES_1969, DEFAULT_DIOCESE_1962, DEFAULT_DIOCESE_1969, type CalendarLang, m } from '../../../../calendarI18n';
import { litBg, litInk, LIT_COLOR_VAR } from '../../../../calendarColors';
import RingView from './RingView.svelte';
import HeroCard from '../../../../HeroCard.svelte';
@@ -23,6 +12,7 @@
let { data }: { data: PageData } = $props();
const lang = $derived(data.lang as CalendarLang);
const t = $derived(m[lang]);
const year = $derived(data.year);
const liturgicalYear = $derived(data.liturgicalYear);
@@ -67,7 +57,7 @@
const rite = $derived(data.rite);
const wip = $derived(data.wip);
const riteSubtitle = $derived(t(rite === 'vetus' ? 'rite1962Long' : 'rite1969Long', lang));
const riteSubtitle = $derived(t[rite === 'vetus' ? 'rite1962Long' : 'rite1969Long']);
function pad(n: number) {
return String(n).padStart(2, '0');
@@ -144,7 +134,7 @@
}) + dioceseQuery;
});
const pageTitle = $derived(t('calendar', lang));
const pageTitle = $derived(t.calendar);
// When switching rites we drop ?diocese because the ID spaces differ (1962 has
// diocesan calendars, 1969 only "general" or "switzerland"). The server
@@ -202,31 +192,31 @@
</a>
</div>
<label class="diocese-picker">
<span class="diocese-label">{t('calendarVariant', lang)}</span>
<select value={diocese} onchange={onDioceseChange} aria-label={t('calendarVariant', lang)}>
<span class="diocese-label">{t.calendarVariant}</span>
<select value={diocese} onchange={onDioceseChange} aria-label={t.calendarVariant}>
{#each dioceseOptions as d (d)}
<option value={d}>{dioceseLabel(d, lang)}</option>
{/each}
</select>
</label>
{#if rite === 'novus'}
<p class="diocese-note">{t('rite1969SwissNote', lang)}</p>
<p class="diocese-note">{t.rite1969SwissNote}</p>
{/if}
</header>
{#if wip}
<section class="wip">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4"/><path d="M12 16h.01"/><circle cx="12" cy="12" r="10"/></svg>
<h2>{t('wipTitle', lang)}</h2>
<p>{t('wipBody', lang)}</p>
<h2>{t.wipTitle}</h2>
<p>{t.wipBody}</p>
</section>
{:else}
{#if rite === 'vetus'}
<aside class="disclaimer" role="note">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
<div>
<strong>{t('rite1962DisclaimerTitle', lang)}</strong>
<p>{t('rite1962DisclaimerBody', lang)}</p>
<strong>{t.rite1962DisclaimerTitle}</strong>
<p>{t.rite1962DisclaimerBody}</p>
</div>
</aside>
{/if}
@@ -241,7 +231,7 @@
<!-- Color legend + view switcher -->
<div class="overview-controls">
<div class="view-switcher" role="tablist" aria-label={t('calendar', lang)}>
<div class="view-switcher" role="tablist" aria-label={t.calendar}>
<button
class:active={view === 'ring'}
role="tab"
@@ -281,7 +271,7 @@
data-sveltekit-replacestate
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('jumpToToday', lang)}
{t.jumpToToday}
</a>
</div>
</div>
@@ -309,7 +299,7 @@
<a
class="nav-btn"
href={monthHref(prevMonth.y, prevMonth.m)}
aria-label={t('prev', lang)}
aria-label={t.prev}
data-sveltekit-noscroll
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
@@ -321,7 +311,7 @@
<a
class="nav-btn"
href={monthHref(nextMonth.y, nextMonth.m)}
aria-label={t('next', lang)}
aria-label={t.next}
data-sveltekit-noscroll
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
@@ -3,19 +3,14 @@
import type { PageData } from './$types';
import { page } from '$app/state';
import { browser } from '$app/environment';
import {
formatLongDate,
getMonthName,
properLabel,
t,
t1962,
type CalendarLang
} from '../../../../../calendarI18n';
import { formatLongDate, getMonthName, properLabel, type CalendarLang, m, m1962 } from '../../../../../calendarI18n';
import HeroCard from '../../../../../HeroCard.svelte';
let { data }: { data: PageData } = $props();
const lang = $derived(data.lang as CalendarLang);
const t1962 = $derived(m1962[lang]);
const t = $derived(m[lang]);
const rite = $derived(data.rite);
const day = $derived(data.day1);
const year = $derived(data.year);
@@ -97,10 +92,10 @@
<span>{monthTitle}</span>
</a>
<div class="day-nav">
<a class="nav-btn" href={prevHref} aria-label={t('prev', lang)} data-sveltekit-noscroll>
<a class="nav-btn" href={prevHref} aria-label={t.prev} data-sveltekit-noscroll>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
</a>
<a class="nav-btn" href={nextHref} aria-label={t('next', lang)} data-sveltekit-noscroll>
<a class="nav-btn" href={nextHref} aria-label={t.next} data-sveltekit-noscroll>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
</a>
</div>
@@ -114,19 +109,19 @@
<dl class="detail-extras">
{#if d.vigilOf}
<div>
<dt>{t1962('vigilOf', lang)}</dt>
<dt>{t1962['vigilOf']}</dt>
<dd>{d.vigilOf}</dd>
</div>
{/if}
{#if d.octave}
<div>
<dt>{t1962('octave', lang)}</dt>
<dd>{d.octave.ofId} · {t1962('octaveDay', lang)} {d.octave.day}</dd>
<dt>{t1962.octave}</dt>
<dd>{d.octave.ofId} · {t1962['octaveDay']} {d.octave.day}</dd>
</div>
{/if}
{#if d.transferredFrom}
<div>
<dt>{t1962('transferredFrom', lang)}</dt>
<dt>{t1962['transferredFrom']}</dt>
<dd>{d.transferredFrom}</dd>
</div>
{/if}
@@ -135,9 +130,9 @@
{#if d.propers.length}
<section class="propers">
<div class="propers-head">
<h4>{t1962('propers', lang)}</h4>
<h4>{t1962.propers}</h4>
{#if lang !== 'la'}
<div class="view-toggle" role="group" aria-label={t1962('propers', lang)}>
<div class="view-toggle" role="group" aria-label={t1962.propers}>
<button
type="button"
class="view-btn"
@@ -145,7 +140,7 @@
aria-pressed={propersView === 'la'}
onclick={() => (propersView = 'la')}
>
{t1962('viewLatin', lang)}
{t1962['viewLatin']}
</button>
<button
type="button"
@@ -154,7 +149,7 @@
aria-pressed={propersView === 'parallel'}
onclick={() => (propersView = 'parallel')}
>
{t1962('viewParallel', lang)}
{t1962['viewParallel']}
</button>
<button
type="button"
@@ -163,7 +158,7 @@
aria-pressed={propersView === 'local'}
onclick={() => (propersView = 'local')}
>
{t1962('viewVernacular', lang)}
{t1962['viewVernacular']}
</button>
</div>
{/if}
@@ -177,9 +172,9 @@
<span class="proper-ref">({section.refLabel})</span>
{/if}
{#if section.localFromBible && lang !== 'la' && propersView !== 'la'}
<span class="proper-fallback" title={t1962('fallbackHint', lang)}>
<span class="proper-fallback" title={t1962['fallbackHint']}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>
{t1962('fallbackBadge', lang)}
{t1962['fallbackBadge']}
</span>
{/if}
</header>
@@ -91,58 +91,22 @@ export function humanizeSundayCycle(raw: string | null): string | null {
return m ? m[1] : raw;
}
export const ui = {
today: { en: 'Today', de: 'Heute', la: 'Hodie' },
calendar: { en: 'Liturgical Calendar', de: 'Liturgischer Kalender', la: 'Calendarium Liturgicum' },
jumpToToday: { en: 'Jump to today', de: 'Zu heute', la: 'Ad hodiernum' },
prev: { en: 'Previous month', de: 'Vorheriger Monat', la: 'Mensis praecedens' },
next: { en: 'Next month', de: 'Nächster Monat', la: 'Mensis sequens' },
psalterWeek: { en: 'Psalter week', de: 'Psalterwoche', la: 'Hebdomada psalterii' },
cycle: { en: 'Sunday cycle', de: 'Lesejahr', la: 'Cyclus dominicalis' },
rite1969Long: {
en: 'Roman Missal of 1969 (Ordinary Form)',
de: 'Römisches Messbuch 1969 (Ordentliche Form)',
la: 'Missale Romanum 1969 (Forma Ordinaria)'
},
rite1962Long: {
en: 'Roman Missal of 1962 (Extraordinary Form)',
de: 'Römisches Messbuch 1962 (Ausserordentliche Form)',
la: 'Missale Romanum 1962 (Forma Extraordinaria)'
},
wipTitle: {
en: 'Work in progress',
de: 'In Arbeit',
la: 'In opere'
},
wipBody: {
en: 'The calendar for the older rite (1962 Missal) is not yet available. Stay tuned.',
de: 'Der Kalender für den alten Ritus (Messbuch 1962) ist noch nicht verfügbar. Bleib dran.',
la: 'Calendarium ritus antiqui (Missale 1962) nondum paratum est. Exspecta paulisper.'
},
rite1962DisclaimerTitle: {
en: 'Accuracy still being verified',
de: 'Genauigkeit wird noch geprüft',
la: 'Accuratio adhuc probanda'
},
rite1962DisclaimerBody: {
en: 'The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Only the Swiss diocesan propers shipped by romcal are applied; other national/local calendars are not yet available.',
de: 'Der Kalender für den Ritus von 1962 stammt aus divinum-officium-Daten und wird noch Tag für Tag mit maßgeblichen Quellen abgeglichen. Nur die in romcal enthaltenen Schweizer Diözesankalender werden angewendet; weitere Landes- oder Ortskalender sind noch nicht verfügbar.',
la: 'Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Tantum calendaria propria dioecesium Helvetiae a romcal provisa adhibentur; cetera calendaria nationalia vel localia nondum praesto sunt.'
},
calendarVariant: {
en: 'Calendar',
de: 'Kalender',
la: 'Calendarium'
},
rite1969SwissNote: {
en: 'romcal ships only a national Swiss calendar for 1969 — diocesan sub-calendars are not available for this rite.',
de: 'romcal liefert für 1969 nur einen nationalen Schweizer Kalender — diözesane Unterkalender sind für diesen Ritus nicht verfügbar.',
la: 'Pro anno 1969 romcal solum calendarium Helvetiae nationale praebet — calendaria dioecesana propria pro hoc ritu non adsunt.'
}
};
import { de as ui_de } from '$lib/i18n/calendar/de';
import { en as ui_en } from '$lib/i18n/calendar/en';
import { la as ui_la } from '$lib/i18n/calendar/la';
export function t(key: keyof typeof ui, lang: CalendarLang): string {
return ui[key][lang] ?? ui[key].en;
/** Calendar UI translations keyed by locale. de.ts is the source of truth. */
export const m = { de: ui_de, en: ui_en, la: ui_la } as const;
export type CalendarKey = keyof typeof ui_de;
/**
* Get a translated UI string. Prefer `m[lang].key` directly in new code
* this helper is kept for the existing call sites and falls back to English
* if the requested locale is missing.
*/
export function t(key: CalendarKey, lang: CalendarLang): string {
return m[lang][key] ?? m.en[key];
}
export type Rite = 'novus' | 'vetus';
@@ -280,29 +244,21 @@ export function colorLabel1962(colorKey: string, lang: CalendarLang): string {
return COLOR_LABEL_1962[colorKey]?.[lang] ?? colorKey;
}
export const ui1962 = {
commemorations: { en: 'Commemorations', de: 'Kommemorationen', la: 'Commemorationes' },
octave: { en: 'Octave', de: 'Oktav', la: 'Octava' },
octaveDay: { en: 'day', de: 'Tag', la: 'dies' },
vigilOf: { en: 'Vigil of', de: 'Vigil von', la: 'Vigilia' },
transferredFrom: { en: 'Transferred from', de: 'Übertragen von', la: 'Translatum ex' },
source: { en: 'Source', de: 'Quelle', la: 'Fons' },
propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' },
stationChurch: { en: 'Station church', de: 'Stationskirche', la: 'Statio' },
viewLatin: { en: 'Latin', de: 'Latein', la: 'Latine' },
viewParallel: { en: 'Parallel', de: 'Parallel', la: 'Parallelum' },
viewVernacular: { en: 'English', de: 'Deutsch', la: 'Vernacula' },
fallbackBadge: { en: 'Douay-Rheims', de: 'Allioli', la: 'Vulgata' },
fallbackHint: {
en: 'Translation not provided in the missal. Text taken from the Douay-Rheims Bible at the cited reference.',
de: 'Keine Übersetzung im Messbuch vorhanden. Text aus der Allioli-Bibelübersetzung an der angegebenen Stelle.',
la: 'Interpretatio localis deest. Textus ex Biblia Sacra locis citatis.'
}
} as const;
import { de as ui1962_de } from '$lib/i18n/calendar/de_1962';
import { en as ui1962_en } from '$lib/i18n/calendar/en_1962';
import { la as ui1962_la } from '$lib/i18n/calendar/la_1962';
export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string {
const entry = ui1962[key] as Record<string, string | undefined>;
return entry[lang] ?? entry.en ?? '';
/** 1962-rite-only UI translations keyed by locale. de_1962.ts is the source of truth. */
export const m1962 = { de: ui1962_de, en: ui1962_en, la: ui1962_la } as const;
export type Calendar1962Key = keyof typeof ui1962_de;
/**
* Get a translated 1962-rite UI string. Prefer `m1962[lang].key` directly
* in new code this helper is kept for the existing call sites.
*/
export function t1962(key: Calendar1962Key, lang: CalendarLang): string {
return m1962[lang][key] ?? m1962.en[key] ?? '';
}
const PROPER_LABEL: Record<string, Record<CalendarLang, string>> = {
@@ -2,6 +2,7 @@
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { m, faithSlugFromLang, prayersSlug } from '$lib/js/faithI18n';
import { createLanguageContext } from "$lib/contexts/languageContext.js";
import Gebet from "./Gebet.svelte";
import LanguageToggle from "$lib/components/faith/LanguageToggle.svelte";
@@ -44,37 +45,38 @@
}
});
const lang = $derived(/** @type {import('$lib/js/faithI18n').FaithLang} */ (data.lang));
const t = $derived(m[lang]);
// Reactive isEnglish based on data.lang
const isEnglish = $derived(data.lang === 'en');
const isLatin = $derived(data.lang === 'la');
const isEnglish = $derived(lang === 'en');
const isLatin = $derived(lang === 'la');
// Prayer-name labels — the language-invariant ones (Glória Patri, Credo,
// Ave Maria, Salve Regina, Glória, Ánima Christi, Tantum Ergo, Angelus,
// Regína Cæli) stay hardcoded; the rest pull from the dictionary.
const labels = $derived({
title: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
description: isLatin
? 'Orationes catholicae in lingua Latina.'
: isEnglish
? 'Catholic prayers in Latin and English.'
: 'Katholische Gebete auf Deutsch und Latein.',
signOfCross: isLatin ? 'Signum Crucis' : isEnglish ? 'The Sign of the Cross' : 'Das heilige Kreuzzeichen',
title: t.prayers,
description: t.prayers_description,
signOfCross: t.sign_of_cross,
gloriaPatri: 'Glória Patri',
paternoster: isLatin ? 'Pater Noster' : isEnglish ? 'Our Father' : 'Paternoster',
paternoster: t.pater_noster,
credo: 'Credo',
aveMaria: 'Ave Maria',
salveRegina: 'Salve Regina',
fatima: isLatin ? 'Oratio Fatimensis' : isEnglish ? 'Fatima Prayer' : 'Das Fatimagebet',
fatima: t.fatima_prayer,
gloria: 'Glória',
michael: isLatin ? 'Oratio ad S. Michaëlem Archangelum' : isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael',
bruderKlaus: isEnglish ? 'Prayer of St. Nicholas of Flüe' : 'Bruder Klaus Gebet',
joseph: isEnglish ? 'Prayer to St. Joseph by Pope St. Pius X' : 'Josephgebet des hl. Papst Pius X',
confiteor: isLatin ? 'Confiteor' : isEnglish ? 'The Confiteor' : 'Das Confiteor',
searchPlaceholder: isLatin ? 'Orationes quaerere...' : isEnglish ? 'Search prayers...' : 'Gebete suchen...',
clearSearch: isLatin ? 'Quaestionem delere' : isEnglish ? 'Clear search' : 'Suche löschen',
textMatch: isLatin ? 'In textu orationis' : isEnglish ? 'Match in prayer text' : 'Treffer im Gebetstext',
postcommunio: isLatin ? 'Orationes post Communionem' : isEnglish ? 'Postcommunio Prayers' : 'Nachkommuniongebete',
michael: t.st_michael_prayer,
bruderKlaus: t.bruder_klaus_prayer,
joseph: t.st_joseph_prayer,
confiteor: t.the_confiteor,
searchPlaceholder: t.search_prayers,
clearSearch: t.clear_search,
textMatch: t.text_match,
postcommunio: t.postcommunio_prayers,
animachristi: 'Ánima Christi',
prayerbeforeacrucifix: isLatin ? 'Oratio ante Crucifixum' : isEnglish ? 'Prayer Before a Crucifix' : 'Gebet vor einem Kruzifix',
guardianAngel: isLatin ? 'Angele Dei' : isEnglish ? 'Guardian Angel Prayer' : 'Schutzengel-Gebet',
apostlesCreed: isLatin ? 'Symbolum Apostolorum' : isEnglish ? "Apostles' Creed" : 'Apostolisches Glaubensbekenntnis',
prayerbeforeacrucifix: t.prayer_before_crucifix,
guardianAngel: t.guardian_angel_prayer,
apostlesCreed: t.apostles_creed,
tantumErgo: 'Tantum Ergo',
angelus: 'Angelus',
reginaCaeli: 'Regína Cæli'
@@ -173,8 +175,8 @@
// Base URL for prayer links
const baseUrl = $derived(resolve('/[faithLang=faithLang]/[prayers=prayersLang]', {
faithLang: isLatin ? 'fides' : isEnglish ? 'faith' : 'glaube',
prayers: isLatin ? 'orationes' : isEnglish ? 'prayers' : 'gebete'
faithLang: faithSlugFromLang(lang),
prayers: prayersSlug(lang)
}));
// Get prayer name by ID (reactive based on language)
@@ -501,14 +503,14 @@ h1{
</div>
{/if}
<nav class="category-filters" aria-label={isLatin ? 'Filtrare per categoriam' : isEnglish ? 'Filter by category' : 'Nach Kategorie filtern'}>
<nav class="category-filters" aria-label={t.filter_by_category}>
<a
href={buildFilterHref(null)}
class="category-pill"
class:selected={!selectedCategory}
onclick={(e) => { e.preventDefault(); selectedCategory = null; }
}
>{isLatin ? 'Omnia' : isEnglish ? 'All' : 'Alle'}</a>
>{t.all_categories}</a>
{#each categories as cat (cat.id)}
<a
href={buildFilterHref(cat.id)}
@@ -575,7 +577,7 @@ h1{
<Postcommunio onlyIntro={true} />
{/if}
{#if prayer.id === 'reginaCaeli' && isEastertide}
<span class="seasonal-badge">{isLatin ? 'Tempus Paschale' : isEnglish ? 'Eastertide' : 'Osterzeit'}</span>
<span class="seasonal-badge">{t.eastertide_badge}</span>
{/if}
</Gebet>
</div>
@@ -24,6 +24,8 @@
import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte";
import StickyImage from "$lib/components/faith/StickyImage.svelte";
import AngelusStreakCounter from "$lib/components/faith/AngelusStreakCounter.svelte";
import { m } from '$lib/js/faithI18n';
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
let { data } = $props();
@@ -37,40 +39,43 @@
}
});
const isEnglish = $derived(data.lang === 'en');
const isLatin = $derived(data.lang === 'la');
const lang = $derived(/** @type {FaithLang} */ (data.lang));
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en');
const isLatin = $derived(lang === 'la');
// Prayer definitions with slugs
// Prayer definitions with slugs. Names come from the dictionary; slugs and
// bilingue flags stay inline since they're prayer-route metadata.
const prayerDefs = $derived({
'das-heilige-kreuzzeichen': { id: 'signOfCross', name: isEnglish ? 'The Sign of the Cross' : 'Das heilige Kreuzzeichen', bilingue: true },
'the-sign-of-the-cross': { id: 'signOfCross', name: isEnglish ? 'The Sign of the Cross' : 'Das heilige Kreuzzeichen', bilingue: true },
'das-heilige-kreuzzeichen': { id: 'signOfCross', name: t.sign_of_cross, bilingue: true },
'the-sign-of-the-cross': { id: 'signOfCross', name: t.sign_of_cross, bilingue: true },
'gloria-patri': { id: 'gloriaPatri', name: 'Glória Patri', bilingue: true },
'paternoster': { id: 'paternoster', name: isEnglish ? 'Our Father' : 'Paternoster', bilingue: true },
'our-father': { id: 'paternoster', name: isEnglish ? 'Our Father' : 'Paternoster', bilingue: true },
'credo': { id: 'credo', name: isEnglish ? 'Nicene Creed' : 'Credo', bilingue: true },
'nicene-creed': { id: 'credo', name: isEnglish ? 'Nicene Creed' : 'Credo', bilingue: true },
'ave-maria': { id: 'aveMaria', name: isEnglish ? 'Hail Mary' : 'Ave Maria', bilingue: true },
'hail-mary': { id: 'aveMaria', name: isEnglish ? 'Hail Mary' : 'Ave Maria', bilingue: true },
'paternoster': { id: 'paternoster', name: t.pater_noster, bilingue: true },
'our-father': { id: 'paternoster', name: t.pater_noster, bilingue: true },
'credo': { id: 'credo', name: t.nicene_creed, bilingue: true },
'nicene-creed': { id: 'credo', name: t.nicene_creed, bilingue: true },
'ave-maria': { id: 'aveMaria', name: t.hail_mary, bilingue: true },
'hail-mary': { id: 'aveMaria', name: t.hail_mary, bilingue: true },
'salve-regina': { id: 'salveRegina', name: 'Salve Regina', bilingue: true },
'das-fatimagebet': { id: 'fatima', name: isEnglish ? 'Fatima Prayer' : 'Das Fatimagebet', bilingue: true },
'fatima-prayer': { id: 'fatima', name: isEnglish ? 'Fatima Prayer' : 'Das Fatimagebet', bilingue: true },
'das-fatimagebet': { id: 'fatima', name: t.fatima_prayer, bilingue: true },
'fatima-prayer': { id: 'fatima', name: t.fatima_prayer, bilingue: true },
'gloria': { id: 'gloria', name: 'Glória', bilingue: true },
'gebet-zum-hl-erzengel-michael': { id: 'michael', name: isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael', bilingue: true },
'prayer-to-st-michael-the-archangel': { id: 'michael', name: isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael', bilingue: true },
'bruder-klaus-gebet': { id: 'bruderKlaus', name: isEnglish ? 'Prayer of St. Nicholas of Flüe' : 'Bruder Klaus Gebet', bilingue: false },
'prayer-of-st-nicholas-of-flue': { id: 'bruderKlaus', name: isEnglish ? 'Prayer of St. Nicholas of Flüe' : 'Bruder Klaus Gebet', bilingue: false },
'josephgebet-des-hl-papst-pius-x': { id: 'joseph', name: isEnglish ? 'Prayer to St. Joseph by Pope St. Pius X' : 'Josephgebet des hl. Papst Pius X', bilingue: false },
'prayer-to-st-joseph-by-pope-st-pius-x': { id: 'joseph', name: isEnglish ? 'Prayer to St. Joseph by Pope St. Pius X' : 'Josephgebet des hl. Papst Pius X', bilingue: false },
'das-confiteor': { id: 'confiteor', name: isEnglish ? 'The Confiteor' : 'Das Confiteor', bilingue: true },
'the-confiteor': { id: 'confiteor', name: isEnglish ? 'The Confiteor' : 'Das Confiteor', bilingue: true },
'postcommunio': { id: 'postcommunio', name: isEnglish ? 'Postcommunio Prayers' : 'Nachkommuniongebete', bilingue: true },
'gebet-zum-hl-erzengel-michael': { id: 'michael', name: t.st_michael_prayer, bilingue: true },
'prayer-to-st-michael-the-archangel': { id: 'michael', name: t.st_michael_prayer, bilingue: true },
'bruder-klaus-gebet': { id: 'bruderKlaus', name: t.bruder_klaus_prayer, bilingue: false },
'prayer-of-st-nicholas-of-flue': { id: 'bruderKlaus', name: t.bruder_klaus_prayer, bilingue: false },
'josephgebet-des-hl-papst-pius-x': { id: 'joseph', name: t.st_joseph_prayer, bilingue: false },
'prayer-to-st-joseph-by-pope-st-pius-x': { id: 'joseph', name: t.st_joseph_prayer, bilingue: false },
'das-confiteor': { id: 'confiteor', name: t.the_confiteor, bilingue: true },
'the-confiteor': { id: 'confiteor', name: t.the_confiteor, bilingue: true },
'postcommunio': { id: 'postcommunio', name: t.postcommunio_prayers, bilingue: true },
'anima-christi': { id: 'animachristi', name: 'Ánima Christi', bilingue: true },
'prayer-before-a-crucifix': { id: 'prayerbeforeacrucifix', name: isEnglish ? 'Prayer Before a Crucifix' : 'Gebet vor einem Kruzifix', bilingue: true },
'gebet-vor-einem-kruzifix': { id: 'prayerbeforeacrucifix', name: isEnglish ? 'Prayer Before a Crucifix' : 'Gebet vor einem Kruzifix', bilingue: true },
'schutzengel-gebet': { id: 'guardianAngel', name: isEnglish ? 'Guardian Angel Prayer' : 'Schutzengel-Gebet', bilingue: true },
'guardian-angel-prayer': { id: 'guardianAngel', name: isEnglish ? 'Guardian Angel Prayer' : 'Schutzengel-Gebet', bilingue: true },
'apostolisches-glaubensbekenntnis': { id: 'apostlesCreed', name: isEnglish ? "Apostles' Creed" : 'Apostolisches Glaubensbekenntnis', bilingue: true },
'apostles-creed': { id: 'apostlesCreed', name: isEnglish ? "Apostles' Creed" : 'Apostolisches Glaubensbekenntnis', bilingue: true },
'prayer-before-a-crucifix': { id: 'prayerbeforeacrucifix', name: t.prayer_before_crucifix, bilingue: true },
'gebet-vor-einem-kruzifix': { id: 'prayerbeforeacrucifix', name: t.prayer_before_crucifix, bilingue: true },
'schutzengel-gebet': { id: 'guardianAngel', name: t.guardian_angel_prayer, bilingue: true },
'guardian-angel-prayer': { id: 'guardianAngel', name: t.guardian_angel_prayer, bilingue: true },
'apostolisches-glaubensbekenntnis': { id: 'apostlesCreed', name: t.apostles_creed, bilingue: true },
'apostles-creed': { id: 'apostlesCreed', name: t.apostles_creed, bilingue: true },
'tantum-ergo': { id: 'tantumErgo', name: 'Tantum Ergo', bilingue: true },
'angelus': { id: 'angelus', name: 'Angelus', bilingue: true },
'regina-caeli': { id: 'reginaCaeli', name: 'Regína Cæli', bilingue: true }
@@ -83,8 +88,8 @@
const isAngelusPage = $derived(prayerId === 'angelus' || prayerId === 'reginaCaeli');
const angelusImageCaption = $derived(prayerId === 'reginaCaeli'
? { artist: 'Diego Velázquez', title: isEnglish ? 'Coronation of the Virgin' : 'Die Krönung der Jungfrau', year: 1641 }
: { artist: 'Bartolomé Esteban Murillo', title: isEnglish ? 'The Annunciation' : 'Die Verkündigung', year: /** @type {number | null} */(null) }
? { artist: 'Diego Velázquez', title: t.painting_coronation_virgin, year: 1641 }
: { artist: 'Bartolomé Esteban Murillo', title: t.painting_annunciation, year: /** @type {number | null} */(null) }
);
const gloriaIntro = $derived(isEnglish
@@ -183,7 +188,7 @@ h1 {
{#if isAngelusPage}
<AngelusStreakCounter
streakData={data.angelusStreak}
lang={isLatin ? 'la' : isEnglish ? 'en' : 'de'}
{lang}
isLoggedIn={!!data.session?.user}
/>
{/if}
@@ -1156,5 +1156,5 @@ h1 {
<!-- Bible citation modal -->
{#if showModal}
<BibleModal reference={selectedReference} title={selectedTitle} verseData={selectedVerseData} lang={data.lang} onClose={() => showModal = false} />
<BibleModal reference={selectedReference} title={selectedTitle} verseData={selectedVerseData} {lang} onClose={() => showModal = false} />
{/if}
@@ -1,9 +1,12 @@
<script>
import { resolve } from '$app/paths';
import LinksGrid from '$lib/components/LinksGrid.svelte';
import { m } from '$lib/js/faithI18n';
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
let { data } = $props();
const isGerman = $derived(data.lang === 'de');
const isLatin = $derived(data.lang === 'la');
const lang = $derived(/** @type {FaithLang} */ (data.lang));
const t = $derived(m[lang]);
const isGerman = $derived(lang === 'de');
</script>
<svelte:head>
@@ -49,7 +52,7 @@
<h1>Katechese</h1>
{#if !isGerman}
<p class="lang-notice">{isLatin ? 'Haec catechesis tantum in ' : 'This catechesis is only available in '}<a href={resolve('/glaube/katechese')}>{isLatin ? 'lingua Germanica' : 'German'}</a>{isLatin ? ' praesto est.' : '.'}</p>
<p class="lang-notice">{t.only_german_pre}<a href={resolve('/glaube/katechese')}>{t.only_german_link}</a>{t.only_german_post}</p>
{/if}
<p>
Aufgearbeitete Lehrinhalte aus dem Glaubenskurs von P. Martin Ramm FSSP.
@@ -5,10 +5,12 @@
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { page } from '$app/state';
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
import { m, langFromFaithSlug } from '$lib/js/faithI18n';
/** @type {number | string | null} */
let expanded = $state(null);
const isGerman = $derived(page.url.pathname.startsWith('/glaube'));
const isLatin = $derived(page.url.pathname.startsWith('/fides'));
const lang = $derived(langFromFaithSlug(page.url.pathname.split('/')[1]));
const t = $derived(m[lang]);
const isGerman = $derived(lang === 'de');
/** @param {number | string} id */
function toggle(id) {
@@ -93,7 +95,7 @@
</header>
{#if !isGerman}
<p class="lang-notice">{isLatin ? 'Haec catechesis tantum in ' : 'This catechesis is only available in '}<a href={resolve('/glaube/katechese/zehn-gebote')}>{isLatin ? 'lingua Germanica' : 'German'}</a>{isLatin ? ' praesto est.' : '.'}</p>
<p class="lang-notice">{t.only_german_pre}<a href={resolve('/glaube/katechese/zehn-gebote')}>{t.only_german_link}</a>{t.only_german_post}</p>
{/if}
<section id="ursprung">
@@ -56,14 +56,17 @@ let { data, children } = $props();
let user = $derived(data.session?.user);
const isEnglish = $derived(data.lang === 'en');
import { m } from '$lib/js/recipesI18n';
/** @typedef {import('$lib/js/recipesI18n').RecipesLang} RecipesLang */
const lang = $derived(/** @type {RecipesLang} */ (data.lang));
const t = $derived(m[lang]);
const labels = $derived({
allRecipes: isEnglish ? 'All Recipes' : 'Alle Rezepte',
favorites: isEnglish ? 'Favorites' : 'Favoriten',
inSeason: isEnglish ? 'Season' : 'Saison',
category: isEnglish ? 'Category' : 'Kategorie',
icon: 'Icon',
keywords: 'Tags'
allRecipes: t.all_recipes,
favorites: t.favorites,
inSeason: t.season_nav,
category: t.category_nav,
icon: t.icon_nav,
keywords: t.tags_nav
});
/** @param {string} path */
+11 -12
View File
@@ -5,8 +5,11 @@
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import { getCategories } from '$lib/js/categories';
import { m, type RecipesLang } from '$lib/js/recipesI18n';
let { data } = $props<{ data: PageData }>();
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
let current_month = new Date().getMonth() + 1;
// Search state
@@ -20,7 +23,7 @@
hasActiveSearch = ids.size < data.all_brief.length;
}
const isEnglish = $derived(data.lang === 'en');
const isEnglish = $derived(lang === 'en');
const categories = $derived(getCategories(data.lang));
// Pick a seasonal hero recipe (changes daily) — only recipes with hashed images
@@ -118,17 +121,13 @@
const hasMore = $derived(visibleCount < displayRecipes.length);
const labels = $derived({
title: isEnglish ? 'Recipes' : 'Rezepte',
subheading: isEnglish
? `${data.all_brief.length} recipes and constantly growing...`
: `${data.all_brief.length} Rezepte und stetig wachsend...`,
all: isEnglish ? 'All' : 'Alle',
inSeason: isEnglish ? 'In Season' : 'In Saison',
metaTitle: isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte',
metaDescription: isEnglish
? "A constantly growing collection of recipes from Bocken's kitchen."
: "Eine stetig wachsende Ansammlung an Rezepten aus der Bockenschen Küche.",
metaAlt: isEnglish ? 'Pasta al Ragu with Linguine' : 'Pasta al Ragu mit Linguine'
title: t.index_title,
subheading: `${data.all_brief.length} ${t.recipes_growing_suffix}`,
all: t.all,
inSeason: t.in_season_now,
metaTitle: t.site_title,
metaDescription: t.recipes_collection_meta,
metaAlt: t.meta_alt_hero
});
</script>
<style>
@@ -5,6 +5,7 @@
import { onMount } from 'svelte';
import ErrorView from '$lib/components/ErrorView.svelte';
import { getErrorTitle, getErrorDescription, errorLabels, pick } from '$lib/js/errorStrings';
import { m } from '$lib/js/recipesI18n';
let status = $derived(page.status);
let error = $derived(page.error as any);
@@ -37,23 +38,22 @@
status === 404 && isEnglishRoute && germanRecipeExists && !checkingGermanRecipe
);
const t = $derived(m[isEnglish ? 'en' : 'de']);
let title = $derived(
status === 404 ? (isEnglish ? 'Recipe Not Found' : 'Rezept nicht gefunden')
: getErrorTitle(status, isEnglish)
status === 404 ? t.recipe_not_found : getErrorTitle(status, isEnglish)
);
let description = $derived(
showGermanFallback
? 'This recipe has not been translated to English yet, but the German version is available.'
: status === 404
? (isEnglish ? 'The requested recipe could not be found.' : 'Das angeforderte Rezept konnte nicht gefunden werden.')
? t.recipe_not_found_desc
: getErrorDescription(status, isEnglish)
);
let details = $derived(
checkingGermanRecipe
? (isEnglish ? 'Checking for German version…' : 'Suche nach deutscher Version…')
: error?.details
checkingGermanRecipe ? t.checking_german_version : error?.details
);
let recipesHref = $derived(resolve('/[recipeLang=recipeLang]', { recipeLang: isEnglishRoute ? 'recipes' : 'rezepte' }));
@@ -75,7 +75,7 @@
{#snippet actions()}
{#if status === 401}
<button class="link link-primary" onclick={login}>{pick(errorLabels.login, isEnglish)}</button>
<a class="link" href={recipesHref}>{isEnglish ? 'Recipes' : 'Rezepte'}</a>
<a class="link" href={recipesHref}>{t.recipes_link}</a>
{:else if showGermanFallback}
<button class="link link-primary" onclick={viewGermanRecipe}>View German recipe</button>
{#if user}
@@ -84,9 +84,9 @@
<a class="link" href={recipesHref}>Recipes</a>
{:else if status === 500}
<button class="link link-primary" onclick={goBack}>{pick(errorLabels.tryAgain, isEnglish)}</button>
<a class="link" href={recipesHref}>{isEnglish ? 'Recipes' : 'Rezepte'}</a>
<a class="link" href={recipesHref}>{t.recipes_link}</a>
{:else}
<a class="link link-primary" href={recipesHref}>{isEnglish ? 'Recipes' : 'Rezepte'}</a>
<a class="link link-primary" href={recipesHref}>{t.recipes_link}</a>
<button class="link" onclick={goBack}>{pick(errorLabels.goBack, isEnglish)}</button>
{/if}
{/snippet}
@@ -14,6 +14,7 @@
import FavoriteButton from '$lib/components/FavoriteButton.svelte';
import { onDestroy } from 'svelte';
import { recipeTranslationStore } from '$lib/stores/recipeTranslation.svelte';
import { m, type RecipesLang } from '$lib/js/recipesI18n';
let { data } = $props<{ data: PageData }>();
@@ -32,7 +33,9 @@
recipeTranslationStore.set(null);
});
const isEnglish = $derived(data.lang === 'en');
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en');
// Use mediapath from images array (includes hash for cache busting)
// Fallback to short_name.webp for backward compatibility
@@ -100,10 +103,10 @@
const formatted_display_date = $derived(display_date.toLocaleDateString(isEnglish ? 'en-US' : 'de-DE', options));
const labels = $derived({
season: isEnglish ? 'Season:' : 'Saison:',
keywords: isEnglish ? 'Keywords:' : 'Stichwörter:',
lastModified: isEnglish ? 'Last modified:' : 'Letzte Änderung:',
title: isEnglish ? "Bocken Recipes" : "Bocken'sche Rezepte"
season: t.season_label,
keywords: t.keywords_colon,
lastModified: t.last_modified,
title: t.site_title_long
});
</script>
<style>
@@ -319,7 +322,7 @@ h2{
<div class=tags>
<h2>{labels.season}</h2>
{#each season_iv as season}
<a class="g-tag" href={resolve('/[recipeLang=recipeLang]/season/[month]', { recipeLang: data.recipeLang, month: season[0] })}>
<a class="g-tag" href={resolve('/[recipeLang=recipeLang]/season/[month]', { recipeLang: data.recipeLang, month: String(season[0]) })}>
{#if season[0]}
{months[season[0] - 1]}
{/if}
@@ -1,50 +1,39 @@
<script lang="ts">
import type { PageData } from './$types';
import { m, type RecipesLang } from '$lib/js/recipesI18n';
let { data } = $props<{ data: PageData }>();
const t = $derived(m[data.lang as RecipesLang]);
// svelte-ignore state_referenced_locally
const isEnglish = data.lang === 'en';
const pageTitle = isEnglish ? 'Administration' : 'Administration';
const pageDescription = isEnglish
? 'Manage recipes and content'
: 'Rezepte und Inhalte verwalten';
const pageTitle = $derived(t.administration_title);
const pageDescription = $derived(t.administration_description);
// svelte-ignore state_referenced_locally
const links = [
const links = $derived([
{
title: isEnglish ? 'Untranslated Recipes' : 'Unübersetzte Rezepte',
description: isEnglish
? 'View and manage recipes that need translation'
: 'Rezepte ansehen und verwalten, die übersetzt werden müssen',
title: t.untranslated_recipes,
description: t.untranslated_description,
href: `/${data.recipeLang}/admin/untranslated`,
icon: '🌐'
},
{
title: isEnglish ? 'Alt-Text Generator' : 'Alt-Text Generator',
description: isEnglish
? 'Generate alternative text for recipe images using AI'
: 'Alternativtext für Rezeptbilder mit KI generieren',
title: t.alt_text_generator,
description: t.alt_text_description,
href: `/${data.recipeLang}/admin/alt-text-generator`,
icon: '🖼️'
},
{
title: isEnglish ? 'Image Colors' : 'Bildfarben',
description: isEnglish
? 'Extract dominant colors from recipe images for loading placeholders'
: 'Dominante Farben aus Rezeptbildern für Ladeplatzhalter extrahieren',
title: t.image_colors,
description: t.image_colors_description,
href: `/${data.recipeLang}/admin/image-colors`,
icon: '🎨'
},
{
title: isEnglish ? 'Nutrition Mappings' : 'Nährwert-Zuordnungen',
description: isEnglish
? 'Generate or regenerate calorie and nutrition data for all recipes'
: 'Kalorien- und Nährwertdaten für alle Rezepte generieren oder aktualisieren',
title: t.nutrition_mappings,
description: t.nutrition_mappings_description,
href: `/${data.recipeLang}/admin/nutrition`,
icon: '🥗'
}
];
]);
</script>
<style>
@@ -5,10 +5,12 @@
import TagCloud from '$lib/components/TagCloud.svelte';
import TagBall from '$lib/components/TagBall.svelte';
const isEnglish = $derived(data.lang === 'en');
import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
const labels = $derived({
title: isEnglish ? 'Categories' : 'Kategorien',
siteTitle: isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte'
title: t.categories_title,
siteTitle: t.site_title
});
</script>
@@ -11,9 +11,11 @@
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
const isEnglish = $derived(data.lang === 'en');
const label = $derived(isEnglish ? 'Recipes in Category' : 'Rezepte in Kategorie');
const siteTitle = $derived(isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte');
import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
const label = $derived(t.recipes_in_category);
const siteTitle = $derived(t.site_title);
let matchedRecipeIds = $state(new Set<string>());
let hasActiveSearch = $state(false);
@@ -7,26 +7,25 @@
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
const isEnglish = $derived(data.lang === 'en');
import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
const countLabel = $derived(
lang === 'en'
? (data.favorites.length === 1 ? t.favorite_recipe_singular : t.favorite_recipes_plural)
: t.favorites_count_label
);
const labels = $derived({
title: isEnglish ? 'Favorites' : 'Favoriten',
pageTitle: isEnglish ? 'My Favorites - Bocken Recipes' : 'Meine Favoriten - Bocken Rezepte',
metaDescription: isEnglish
? 'My favorite recipes from Bocken\'s kitchen.'
: 'Meine favorisierten Rezepte aus der Bockenschen Küche.',
count: isEnglish
? `${data.favorites.length} favorite recipe${data.favorites.length !== 1 ? 's' : ''}`
: `${data.favorites.length} favorisierte Rezepte`,
noFavorites: isEnglish ? 'No favorites saved yet' : 'Noch keine Favoriten gespeichert',
errorLoading: isEnglish ? 'Error loading favorites:' : 'Fehler beim Laden der Favoriten:',
emptyState1: isEnglish
? 'You haven\'t saved any recipes as favorites yet.'
: 'Du hast noch keine Rezepte als Favoriten gespeichert.',
emptyState2: isEnglish
? 'Visit a recipe and click the heart icon to add it to your favorites.'
: 'Besuche ein Rezept und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.',
recipesLink: isEnglish ? 'recipe' : 'Rezept',
toTry: isEnglish ? 'Recipes to try' : 'Zum Ausprobieren'
title: t.favorites,
pageTitle: t.favorites_page_title,
metaDescription: t.favorites_meta_description,
count: `${data.favorites.length} ${countLabel}`,
noFavorites: t.no_favorites_yet,
errorLoading: t.error_loading_favorites,
emptyState1: t.empty_favorites_1,
emptyState2: t.empty_favorites_2,
recipesLink: t.recipe_singular_link,
toTry: t.recipes_to_try_link
});
let matchedRecipeIds = $state(new Set());
@@ -106,7 +105,7 @@
</div>
{:else if data.favorites.length > 0}
<div class="empty-state">
<p>{isEnglish ? 'No matching favorites found.' : 'Keine passenden Favoriten gefunden.'}</p>
<p>{t.no_matching_favorites}</p>
</div>
{:else}
<div class="empty-state">
@@ -3,10 +3,13 @@
import type { PageData } from './$types';
let { data } = $props<{ data: PageData }>();
const isEnglish = $derived(data.lang === 'en');
import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
const isEnglish = $derived(lang === 'en');
const labels = $derived({
title: isEnglish ? 'Icons' : 'Icons',
siteTitle: isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte'
title: t.icons_title,
siteTitle: t.site_title
});
</script>
@@ -6,8 +6,10 @@
let { data } = $props<{ data: PageData }>();
import { rand_array } from '$lib/js/randomize';
const isEnglish = $derived(data.lang === 'en');
const siteTitle = $derived(isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte');
import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
const siteTitle = $derived(t.site_title);
// Search state
let matchedRecipeIds = $state(new Set());
@@ -2,8 +2,10 @@
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { m, type RecipesLang } from '$lib/js/recipesI18n';
let { data } = $props();
const t = $derived(m[data.lang as RecipesLang]);
// This page serves as an "app shell" that gets cached by the service worker.
// When a user directly navigates to a recipe page while offline and that exact
@@ -36,7 +38,7 @@ onMount(() => {
<div class="offline-shell">
<div class="loading-spinner"></div>
<p>{data.lang === 'en' ? 'Loading offline content...' : 'Lade Offline-Inhalte...'}</p>
<p>{t.loading_offline}</p>
</div>
<style>
@@ -3,28 +3,26 @@
import type { PageData } from './$types';
import Search from '$lib/components/recipes/Search.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import { m, type RecipesLang } from '$lib/js/recipesI18n';
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
const isEnglish = $derived(data.lang === 'en');
const lang = $derived(data.lang as RecipesLang);
const t = $derived(m[lang]);
const labels = $derived({
title: isEnglish ? 'Search Results' : 'Suchergebnisse',
pageTitle: isEnglish
? `Search Results${data.query ? ` for "${data.query}"` : ''} - Bocken Recipes`
: `Suchergebnisse${data.query ? ` für "${data.query}"` : ''} - Bocken Rezepte`,
metaDescription: isEnglish
? 'Search results in Bocken\'s recipes.'
: 'Suchergebnisse in den Bockenschen Rezepten.',
filteredBy: isEnglish ? 'Filtered by:' : 'Gefiltert nach:',
category: isEnglish ? 'Category' : 'Kategorie',
keywords: isEnglish ? 'Keywords' : 'Stichwörter',
icon: 'Icon',
seasons: isEnglish ? 'Seasons' : 'Monate',
favoritesOnly: isEnglish ? 'Favorites only' : 'Nur Favoriten',
searchError: isEnglish ? 'Search error:' : 'Fehler bei der Suche:',
resultsFor: isEnglish ? 'results for' : 'Ergebnisse für',
noResults: isEnglish ? 'No recipes found.' : 'Keine Rezepte gefunden.',
tryOther: isEnglish ? 'Try different search terms.' : 'Versuche es mit anderen Suchbegriffen.'
title: t.search_results_title,
pageTitle: `${t.search_results_title}${data.query ? ` ${t.search_results_for_word} "${data.query}"` : ''} - ${t.site_title}`,
metaDescription: t.search_meta_description,
filteredBy: t.filtered_by,
category: t.category_nav,
keywords: t.keywords_label,
icon: t.icon_nav,
seasons: t.seasons_label,
favoritesOnly: t.favorites_only,
searchError: t.search_error,
resultsFor: t.results_for,
noResults: t.no_recipes_found,
tryOther: t.try_other_search
});
// Search state for live filtering

Some files were not shown because too many files have changed in this diff Show More