Compare commits
17 Commits
4ad218cc39
...
2e8685d02b
| Author | SHA1 | Date | |
|---|---|---|---|
|
2e8685d02b
|
|||
|
bcdb9a9c4b
|
|||
|
dbce9629a5
|
|||
|
79f4dbb101
|
|||
|
71f7322624
|
|||
|
bd9e9b397f
|
|||
|
ea1a85e935
|
|||
|
d540b82e85
|
|||
|
d7f96f35c2
|
|||
|
3dcb5c7f2b
|
|||
|
28b96a8dc0
|
|||
|
3347619816
|
|||
|
ac05367ee4
|
|||
|
609405da81
|
|||
|
c521a9ec68
|
|||
|
936c59debc
|
|||
|
d8abcbf74b
|
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.52.1",
|
||||
"version": "1.57.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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`);
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} × {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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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 Trainingskalorien). 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;
|
||||
@@ -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 Adam’s 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 — don’t 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>;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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: glaube→de, faith→en, fides→la.
|
||||
* 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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: rezepte→de,
|
||||
* recipes→en. 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+10
-14
@@ -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' }
|
||||
|
||||
+9
-9
@@ -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>
|
||||
|
||||
|
||||
+8
-6
@@ -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}
|
||||
|
||||
+15
-25
@@ -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>
|
||||
|
||||
+16
-21
@@ -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 */
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user