feat: Introduce language filter with DE/EN/ALL toggle for menu descriptions and update to version 1.6.0.

This commit is contained in:
Kantine Wrapper
2026-03-04 13:11:34 +01:00
parent 4aa67c9cbe
commit 8b15760463
10 changed files with 334 additions and 16 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -164,6 +164,38 @@ body {
color: var(--text-secondary);
}
/* Language Toggle (FR-100) */
.lang-toggle {
display: inline-flex;
gap: 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.lang-btn {
padding: 3px 10px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
background: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
color: var(--text-primary);
background: rgba(100, 116, 139, 0.1);
}
.lang-btn.active {
background: var(--accent-color);
color: white;
}
.nav-group {
display: flex;
background-color: var(--bg-card);
@@ -924,6 +956,7 @@ body {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0.75rem;
white-space: pre-wrap;
}
.badges {
@@ -1984,6 +2017,7 @@ body {
let orderMap = new Map();
let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
let pollIntervalId = null;
let langMode = localStorage.getItem('kantine_lang') || 'de';
// === API Helpers ===
function apiHeaders(token) {
@@ -2031,7 +2065,7 @@ body {
<div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.5.1</small></h1>
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.6.0</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div>
</div>
<div class="nav-group" style="margin-left: 1rem;">
@@ -2043,6 +2077,11 @@ body {
</button>
</div>
<div class="header-center-wrapper">
<div id="lang-toggle" class="lang-toggle" title="Sprache der Menübeschreibung">
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">DE</button>
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">EN</button>
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">ALL</button>
</div>
<div id="header-week-info" class="header-week-info"></div>
<div id="weekly-cost-display" class="weekly-cost hidden"></div>
</div>
@@ -2173,7 +2212,7 @@ body {
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.5.1</span>
<strong>Aktuell:</strong> <span id="version-current">v1.6.0</span>
</div>
<div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2241,6 +2280,17 @@ body {
const historyModal = document.getElementById('history-modal');
const btnHistoryClose = document.getElementById('btn-history-close');
// Language Toggle
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', () => {
langMode = btn.dataset.lang;
localStorage.setItem('kantine_lang', langMode);
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderVisibleWeeks();
});
});
if (btnHighlights) {
btnHighlights.addEventListener('click', () => {
highlightsModal.classList.remove('hidden');
@@ -3938,7 +3988,7 @@ body {
<div class="badges">${statusBadge}</div>
</div>
${tagsHtml}
<p class="item-desc">${escapeHtml(item.description)}</p>`;
<p class="item-desc">${escapeHtml(getLocalizedText(item.description))}</p>`;
// Event: Order
const orderBtn = itemEl.querySelector('.btn-order');
@@ -4030,7 +4080,7 @@ body {
// Periodic update check (runs on init + every hour)
async function checkForUpdates() {
const currentVersion = 'v1.5.1';
const currentVersion = 'v1.6.0';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try {
@@ -4071,7 +4121,7 @@ body {
const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.5.1';
const currentVersion = 'v1.6.0';
if (!modal) return;
modal.classList.remove('hidden');
@@ -4291,6 +4341,108 @@ body {
return div.innerHTML;
}
// === Language Filter (FR-100) ===
// DE keywords for fallback language detection
const DE_KEYWORDS = ['mit', 'und', 'oder', 'vom', 'dazu', 'auf', 'nach', 'ein', 'eine', 'der', 'die', 'das', 'aus', 'in', 'an', 'für',
'suppe', 'salat', 'gemüse', 'reis', 'nudeln', 'kartoffel', 'fleisch', 'soße', 'sauce', 'brot', 'joghurt',
'gebraten', 'gekocht', 'gegrillt', 'überbacken', 'gefüllt', 'frisch', 'hausgemacht'];
const EN_KEYWORDS = ['with', 'and', 'or', 'from', 'served', 'on', 'in', 'a', 'the', 'of', 'for',
'soup', 'salad', 'vegetables', 'rice', 'pasta', 'potato', 'meat', 'sauce', 'bread', 'yogurt',
'fried', 'cooked', 'grilled', 'baked', 'stuffed', 'fresh', 'homemade'];
/**
* Splits bilingual menu text into DE and EN parts.
* Pattern per course: [DE] / [EN](ALLERGENS)
* Max 3 courses per menu item (sanity check).
* @param {string} text - The bilingual description text
* @returns {{ de: string, en: string, raw: string }}
*/
function splitLanguage(text) {
if (!text) return { de: '', en: '', raw: '' };
const raw = text;
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• ');
// Check if text contains the bilingual separator ' / '
if (!text.includes(' / ')) {
// Fallback: detect language via keyword scoring
const words = text.toLowerCase().split(/\s+/);
let deScore = 0, enScore = 0;
words.forEach(w => {
const clean = w.replace(/[^a-zäöüß]/g, '');
if (DE_KEYWORDS.includes(clean)) deScore++;
if (EN_KEYWORDS.includes(clean)) enScore++;
});
// No split possible return full text for detected language, empty for other
if (enScore > deScore) {
return { de: '', en: formattedRaw, raw: formattedRaw };
}
return { de: formattedRaw, en: '', raw: formattedRaw };
}
// Split by ' / ' produces alternating DE/EN fragments
const parts = text.split(' / ');
// Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) {
// Too many slashes possibly not bilingual, return as-is
return { de: formattedRaw, en: '', raw: formattedRaw };
}
const deParts = [];
const enParts = [];
// First fragment is always DE (course 1)
deParts.push(parts[0].trim());
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE"
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary
const allergenRegex = /\(([A-Z ]+)\)\s*/;
for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim();
const match = fragment.match(allergenRegex);
if (match) {
// Split: everything before allergen + allergen = EN, after = next DE
const allergenEnd = match.index + match[0].length;
const enPart = fragment.substring(0, match.index).trim();
const allergenCode = match[1];
const nextDe = fragment.substring(allergenEnd).trim();
enParts.push(enPart + '(' + allergenCode + ')');
// Also append allergen to the last DE part
if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
}
if (nextDe) {
deParts.push(nextDe);
}
} else {
// No allergen code this is the last EN part
enParts.push(fragment);
}
}
return {
de: deParts.map(p => '• ' + p).join('\n'),
en: enParts.map(p => '• ' + p).join('\n'),
raw: formattedRaw
};
}
/**
* Returns text filtered by the current language mode.
* @param {string} text - The bilingual text
* @returns {string}
*/
function getLocalizedText(text) {
if (langMode === 'all') return text || '';
const split = splitLanguage(text);
if (langMode === 'en') return split.en || split.raw;
return split.de || split.raw; // 'de' is default
}
// === Bootstrap ===
injectUI();
bindEvents();