feat: Text 2 als feste Inhaltsangabe und Langtext 1-4 als aufklappbare Details (Hover/Touch)
All checks were successful
Build and Deploy / build (push) Successful in 54s

This commit is contained in:
DanielS
2026-09-22 23:36:12 +02:00
parent 7f1e17db2b
commit 1b0726deef
4 changed files with 165 additions and 24 deletions

14
db.js
View File

@@ -3,7 +3,6 @@ const path = require('path');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
// Verzeichnis für Persistenz sicherstellen (/data im Docker Container oder local dir)
const dataDir = process.env.DATA_DIR || (fs.existsSync('/data') ? '/data' : __dirname); const dataDir = process.env.DATA_DIR || (fs.existsSync('/data') ? '/data' : __dirname);
if (!fs.existsSync(dataDir)) { if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true }); fs.mkdirSync(dataDir, { recursive: true });
@@ -12,7 +11,6 @@ if (!fs.existsSync(dataDir)) {
const dbPath = path.join(dataDir, 'data.db'); const dbPath = path.join(dataDir, 'data.db');
const db = new Database(dbPath); const db = new Database(dbPath);
// Standard Journal Mode
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
// Tabellen anlegen // Tabellen anlegen
@@ -32,6 +30,10 @@ db.exec(`
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
text1 TEXT NOT NULL, text1 TEXT NOT NULL,
text2 TEXT, text2 TEXT,
langtext1 TEXT,
langtext2 TEXT,
langtext3 TEXT,
langtext4 TEXT,
group_id TEXT, group_id TEXT,
preis1 REAL DEFAULT 0, preis1 REAL DEFAULT 0,
unit TEXT, unit TEXT,
@@ -49,6 +51,14 @@ db.exec(`
); );
`); `);
// Migration für bestehende DBs falls Spalten fehlen
const tableInfo = db.prepare("PRAGMA table_info(products)").all();
const cols = tableInfo.map(c => c.name);
if (!cols.includes('langtext1')) db.exec("ALTER TABLE products ADD COLUMN langtext1 TEXT;");
if (!cols.includes('langtext2')) db.exec("ALTER TABLE products ADD COLUMN langtext2 TEXT;");
if (!cols.includes('langtext3')) db.exec("ALTER TABLE products ADD COLUMN langtext3 TEXT;");
if (!cols.includes('langtext4')) db.exec("ALTER TABLE products ADD COLUMN langtext4 TEXT;");
// Standard Admin Initialisieren // Standard Admin Initialisieren
const adminUser = db.prepare('SELECT value FROM config WHERE key = ?').get('admin_user'); const adminUser = db.prepare('SELECT value FROM config WHERE key = ?').get('admin_user');
if (!adminUser) { if (!adminUser) {

View File

@@ -182,11 +182,15 @@ async function parseAndStoreZip(buffer) {
`); `);
const insertProduct = db.prepare(` const insertProduct = db.prepare(`
INSERT INTO products (id, text1, text2, group_id, preis1, unit, active) INSERT INTO products (id, text1, text2, langtext1, langtext2, langtext3, langtext4, group_id, preis1, unit, active)
VALUES (?, ?, ?, ?, ?, ?, 1) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
text1=excluded.text1, text1=excluded.text1,
text2=excluded.text2, text2=excluded.text2,
langtext1=excluded.langtext1,
langtext2=excluded.langtext2,
langtext3=excluded.langtext3,
langtext4=excluded.langtext4,
group_id=excluded.group_id, group_id=excluded.group_id,
preis1=excluded.preis1, preis1=excluded.preis1,
unit=excluded.unit, unit=excluded.unit,
@@ -228,6 +232,31 @@ async function parseAndStoreZip(buffer) {
const text2 = item.text2 || item.description || item.additionalText || ''; const text2 = item.text2 || item.description || item.additionalText || '';
const groupId = String(item.groupId || item.warengruppe || ''); const groupId = String(item.groupId || item.warengruppe || '');
// Langtexte aus Attributen / Descriptions / Feldern extrahieren
let langtext1 = item.langtext1 || item.Langtext1 || item.longText1 || item.LongText1 || '';
let langtext2 = item.langtext2 || item.Langtext2 || item.longText2 || item.LongText2 || '';
let langtext3 = item.langtext3 || item.Langtext3 || item.longText3 || item.LongText3 || '';
let langtext4 = item.langtext4 || item.Langtext4 || item.longText4 || item.LongText4 || '';
// Falls in descriptions Array oder attributes Array abgelegt
if (Array.isArray(item.descriptions)) {
if (item.descriptions[0]?.text) langtext1 = item.descriptions[0].text;
if (item.descriptions[1]?.text) langtext2 = item.descriptions[1].text;
if (item.descriptions[2]?.text) langtext3 = item.descriptions[2].text;
if (item.descriptions[3]?.text) langtext4 = item.descriptions[3].text;
}
if (Array.isArray(item.attributes)) {
for (const attr of item.attributes) {
const attrName = (attr.name || attr.title || '').toLowerCase();
const attrVal = attr.value || attr.text || '';
if (attrName.includes('langtext 1') || attrName.includes('langtext1')) langtext1 = attrVal;
if (attrName.includes('langtext 2') || attrName.includes('langtext2')) langtext2 = attrVal;
if (attrName.includes('langtext 3') || attrName.includes('langtext3')) langtext3 = attrVal;
if (attrName.includes('langtext 4') || attrName.includes('langtext4')) langtext4 = attrVal;
}
}
// Preis 1 ermitteln (VK1) // Preis 1 ermitteln (VK1)
let preis1 = 0; let preis1 = 0;
if (Array.isArray(item.prices) && item.prices.length > 0) { if (Array.isArray(item.prices) && item.prices.length > 0) {
@@ -246,7 +275,7 @@ async function parseAndStoreZip(buffer) {
const unit = item.contentUnit || item.salesUnit || item.unit || ''; const unit = item.contentUnit || item.salesUnit || item.unit || '';
if (id) { if (id) {
insertProduct.run(id, text1, text2, groupId, isNaN(preis1) ? 0 : preis1, unit); insertProduct.run(id, text1, text2, langtext1, langtext2, langtext3, langtext4, groupId, isNaN(preis1) ? 0 : preis1, unit);
productsCount++; productsCount++;
} }
} }
@@ -282,9 +311,13 @@ async function parseAndStoreZip(buffer) {
const id = String(item.id || item.articleNo || ''); const id = String(item.id || item.articleNo || '');
const text1 = item.text1 || item.name || 'Unbekannt'; const text1 = item.text1 || item.name || 'Unbekannt';
const text2 = item.text2 || ''; const text2 = item.text2 || '';
const langtext1 = item.langtext1 || item.Langtext1 || '';
const langtext2 = item.langtext2 || item.Langtext2 || '';
const langtext3 = item.langtext3 || item.Langtext3 || '';
const langtext4 = item.langtext4 || item.Langtext4 || '';
const groupId = String(item.groupId || ''); const groupId = String(item.groupId || '');
let preis1 = parseFloat(String(item.preis1 || item.price || 0).replace(',', '.')); let preis1 = parseFloat(String(item.preis1 || item.price || 0).replace(',', '.'));
if (id) { insertProduct.run(id, text1, text2, groupId, isNaN(preis1) ? 0 : preis1, ''); productsCount++; } if (id) { insertProduct.run(id, text1, text2, langtext1, langtext2, langtext3, langtext4, groupId, isNaN(preis1) ? 0 : preis1, ''); productsCount++; }
} }
} }
} }
@@ -298,11 +331,15 @@ async function parseAndStoreZip(buffer) {
function importJsonData(data) { function importJsonData(data) {
const insertProduct = db.prepare(` const insertProduct = db.prepare(`
INSERT INTO products (id, text1, text2, group_id, preis1, unit, active) INSERT INTO products (id, text1, text2, langtext1, langtext2, langtext3, langtext4, group_id, preis1, unit, active)
VALUES (?, ?, ?, ?, ?, ?, 1) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
text1=excluded.text1, text1=excluded.text1,
text2=excluded.text2, text2=excluded.text2,
langtext1=excluded.langtext1,
langtext2=excluded.langtext2,
langtext3=excluded.langtext3,
langtext4=excluded.langtext4,
group_id=excluded.group_id, group_id=excluded.group_id,
preis1=excluded.preis1, preis1=excluded.preis1,
unit=excluded.unit, unit=excluded.unit,
@@ -329,6 +366,10 @@ function importJsonData(data) {
String(p.id || Math.random().toString(36).substring(2)), String(p.id || Math.random().toString(36).substring(2)),
p.text1 || 'Unbekannt', p.text1 || 'Unbekannt',
p.text2 || '', p.text2 || '',
p.langtext1 || '',
p.langtext2 || '',
p.langtext3 || '',
p.langtext4 || '',
String(p.groupId || ''), String(p.groupId || ''),
parseFloat(String(p.preis1 || p.price || 0).replace(',', '.')), parseFloat(String(p.preis1 || p.price || 0).replace(',', '.')),
p.unit || '' p.unit || ''

View File

@@ -274,6 +274,11 @@
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
cursor: pointer;
}
.card:hover {
border-color: rgba(255, 255, 255, 0.18);
} }
.card:active { .card:active {
@@ -284,13 +289,38 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 6px; margin-bottom: 4px;
}
.card-name-row {
display: flex;
align-items: center;
gap: 6px;
} }
.card-name { .card-name {
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
transition: color 0.2s;
}
.card:hover .card-name {
color: var(--primary);
}
.expand-indicator {
font-size: 12px;
color: var(--text-muted);
transition: transform 0.3s ease, color 0.2s ease;
}
.card:hover .expand-indicator, .card.expanded .expand-indicator {
color: var(--primary);
}
.card.expanded .expand-indicator {
transform: rotate(180deg);
} }
.card-price { .card-price {
@@ -304,10 +334,50 @@
border: 1px solid rgba(245, 158, 11, 0.2); border: 1px solid rgba(245, 158, 11, 0.2);
} }
.card-ingredients { /* Text 2 - Feste Inhaltsangabe direkt unter dem Namen */
font-size: 12.5px; .card-content-desc {
color: var(--text-muted); font-size: 13px;
color: var(--text);
opacity: 0.9;
line-height: 1.45; line-height: 1.45;
margin-top: 2px;
}
/* Aufklappbarer Bereich (Langtext 1 bis 4) */
.card-extra-details {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.35s ease, opacity 0.3s ease, margin-top 0.3s ease;
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 0;
padding-top: 0;
}
/* Bei Hover am Desktop oder Klick/Tap am Handy aufklappen */
.card:hover .card-extra-details, .card.expanded .card-extra-details {
max-height: 350px;
opacity: 1;
margin-top: 10px;
padding-top: 8px;
}
.detail-item {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
display: flex;
gap: 6px;
}
.detail-item strong {
color: var(--primary);
font-weight: 600;
min-width: 70px;
flex-shrink: 0;
} }
.highlight { .highlight {
@@ -448,7 +518,6 @@
}); });
} }
// Slider Controls (Pfeile + Mouse Drag)
function initSliderControls() { function initSliderControls() {
const slider = document.getElementById('category-swipe'); const slider = document.getElementById('category-swipe');
const btnLeft = document.getElementById('slide-left'); const btnLeft = document.getElementById('slide-left');
@@ -462,7 +531,6 @@
slider.scrollBy({ left: 160, behavior: 'smooth' }); slider.scrollBy({ left: 160, behavior: 'smooth' });
}); });
// Mouse Drag Unterstützung am Desktop
let isDown = false; let isDown = false;
let startX; let startX;
let scrollLeft; let scrollLeft;
@@ -498,7 +566,11 @@
if (!query) return true; if (!query) return true;
const t1 = (item.text1 || '').toLowerCase(); const t1 = (item.text1 || '').toLowerCase();
const t2 = (item.text2 || '').toLowerCase(); const t2 = (item.text2 || '').toLowerCase();
return t1.includes(query) || t2.includes(query); const l1 = (item.langtext1 || '').toLowerCase();
const l2 = (item.langtext2 || '').toLowerCase();
const l3 = (item.langtext3 || '').toLowerCase();
const l4 = (item.langtext4 || '').toLowerCase();
return t1.includes(query) || t2.includes(query) || l1.includes(query) || l2.includes(query) || l3.includes(query) || l4.includes(query);
}); });
if (matchingItems.length === 0) return null; if (matchingItems.length === 0) return null;
@@ -525,15 +597,33 @@
<section> <section>
<div class="section-title">${escapeHtml(cat.name)} (${cat.items.length})</div> <div class="section-title">${escapeHtml(cat.name)} (${cat.items.length})</div>
<div class="menu-grid"> <div class="menu-grid">
${cat.items.map(item => ` ${cat.items.map(item => {
<div class="card"> const hasExtras = item.langtext1 || item.langtext2 || item.langtext3 || item.langtext4;
<div class="card-top"> return `
<div class="card-name">${highlightText(item.text1, query)}</div> <div class="card" onclick="this.classList.toggle('expanded')">
<div class="card-price">${parseFloat(item.preis1).toFixed(2).replace('.', ',')} €</div> <div class="card-top">
<div class="card-name-row">
<div class="card-name">${highlightText(item.text1, query)}</div>
${hasExtras ? '<span class="expand-indicator">▼</span>' : ''}
</div>
<div class="card-price">${parseFloat(item.preis1).toFixed(2).replace('.', ',')} €</div>
</div>
<!-- Text 2: Feste Inhaltsangabe direkt unter Name -->
${item.text2 ? `<div class="card-content-desc">${highlightText(item.text2, query)}</div>` : ''}
<!-- Aufklappbare Details bei Hover / Klick -->
${hasExtras ? `
<div class="card-extra-details">
${item.langtext1 ? `<div class="detail-item"><strong>Details:</strong> <span>${highlightText(item.langtext1, query)}</span></div>` : ''}
${item.langtext2 ? `<div class="detail-item"><strong>Zutaten:</strong> <span>${highlightText(item.langtext2, query)}</span></div>` : ''}
${item.langtext3 ? `<div class="detail-item"><strong>Hinweise:</strong> <span>${highlightText(item.langtext3, query)}</span></div>` : ''}
${item.langtext4 ? `<div class="detail-item"><strong>Allergene:</strong> <span>${highlightText(item.langtext4, query)}</span></div>` : ''}
</div>
` : ''}
</div> </div>
${item.text2 ? `<div class="card-ingredients">${highlightText(item.text2, query)}</div>` : ''} `;
</div> }).join('')}
`).join('')}
</div> </div>
</section> </section>
`; `;

View File

@@ -45,7 +45,7 @@ function authMiddleware(req, res, next) {
// 1. Produkte, Gruppen & Theme Settings für die Speisekarte // 1. Produkte, Gruppen & Theme Settings für die Speisekarte
app.get('/api/menu', (req, res) => { app.get('/api/menu', (req, res) => {
const groups = db.prepare('SELECT * FROM product_groups ORDER BY sort_order ASC, name ASC').all(); const groups = db.prepare('SELECT * FROM product_groups ORDER BY sort_order ASC, name ASC').all();
const products = db.prepare('SELECT id, text1, text2, group_id, preis1, unit FROM products WHERE active = 1 ORDER BY sort_order ASC, text1 ASC').all(); const products = db.prepare('SELECT id, text1, text2, langtext1, langtext2, langtext3, langtext4, group_id, preis1, unit FROM products WHERE active = 1 ORDER BY sort_order ASC, text1 ASC').all();
// Globale Einstellungen laden // Globale Einstellungen laden
const configRows = db.prepare('SELECT key, value FROM config').all(); const configRows = db.prepare('SELECT key, value FROM config').all();