diff --git a/backend/services/dashboardService.js b/backend/services/dashboardService.js index 267bdd4..b3277ec 100644 --- a/backend/services/dashboardService.js +++ b/backend/services/dashboardService.js @@ -45,10 +45,13 @@ class DashboardService extends BaseService { title: String(w?.title ?? ''), endpoint: String(w?.endpoint ?? '') })).filter(w => w.id && (w.title || w.endpoint)); - await UserDashboard.upsert({ - userId: user.id, - config: { widgets: sanitized } - }, { conflictFields: ['userId'] }); + const payload = { widgets: sanitized }; + const existing = await UserDashboard.findOne({ where: { userId: user.id } }); + if (existing) { + await existing.update({ config: payload }); + } else { + await UserDashboard.create({ userId: user.id, config: payload }); + } return { widgets: sanitized }; } } diff --git a/backend/services/newsService.js b/backend/services/newsService.js index 2cc1353..36ad4f6 100644 --- a/backend/services/newsService.js +++ b/backend/services/newsService.js @@ -38,33 +38,34 @@ async function fetchNewsPage({ counter, language = 'de', category = 'top', nextP } /** - * Liefert die Seite für das „counter.“-te Widget (0 = erste Seite, 1 = zweite, …). - * Ruft die API ggf. mehrfach auf (nextPage), damit jede Widget-Instanz andere Artikel bekommt. + * Liefert den N-ten Artikel (counter = 0, 1, 2, …) für das N-te News-Widget. + * Lädt ggf. mehrere Seiten, bis genug Artikel vorhanden sind; jede Widget-Instanz bekommt einen anderen Artikel. * * @param {object} options - * @param {number} options.counter + * @param {number} options.counter - Index des Artikels (0 = erster, 1 = zweiter, …) * @param {string} [options.language] * @param {string} [options.category] * @returns {Promise<{ results: Array, nextPage: string|null }>} */ async function getNews({ counter = 0, language = 'de', category = 'top' }) { + const neededIndex = Math.max(0, counter); + const collected = []; let nextPageToken = null; - let lastResult = { results: [], nextPage: null }; - for (let i = 0; i <= counter; i++) { - lastResult = await fetchNewsPage({ - counter: i, + while (collected.length <= neededIndex) { + const page = await fetchNewsPage({ language, category, nextPageToken: nextPageToken || undefined }); - nextPageToken = lastResult.nextPage; - if (!nextPageToken && i < counter) { - break; - } + const items = page.results ?? []; + collected.push(...items); + nextPageToken = page.nextPage ?? null; + if (items.length === 0 || !nextPageToken) break; } - return lastResult; + const single = collected[neededIndex] ? [collected[neededIndex]] : []; + return { results: single, nextPage: null }; } export default { getNews }; diff --git a/backend/utils/syncDatabase.js b/backend/utils/syncDatabase.js index 66c0780..3a3dfd3 100644 --- a/backend/utils/syncDatabase.js +++ b/backend/utils/syncDatabase.js @@ -108,6 +108,23 @@ const syncDatabase = async () => { console.warn('⚠️ Konnte type.widget_type nicht anlegen:', e?.message || e); } + // Dashboard: Benutzer-Konfiguration (Widgets pro User) + console.log("Ensuring user_dashboard table exists..."); + try { + await sequelize.query(` + CREATE TABLE IF NOT EXISTS community.user_dashboard ( + user_id INTEGER NOT NULL PRIMARY KEY, + config JSONB NOT NULL DEFAULT '{"widgets":[]}'::jsonb, + CONSTRAINT user_dashboard_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE + ); + `); + } catch (e) { + console.warn('⚠️ Konnte community.user_dashboard nicht anlegen:', e?.message || e); + } + // Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations) // Hintergrund: In Produktion sind Schema-Updates deaktiviert, und Migrations werden nicht automatisch ausgeführt. // Damit API/Menu nicht mit "relation does not exist" (42P01) scheitert, legen wir die Tabellen idempotent an. diff --git a/frontend/src/views/home/LoggedInView.vue b/frontend/src/views/home/LoggedInView.vue index 152f06e..5898d4a 100644 --- a/frontend/src/views/home/LoggedInView.vue +++ b/frontend/src/views/home/LoggedInView.vue @@ -28,6 +28,14 @@ {{ wt.label }} + + Nochmal hinzufügen + Fertig @@ -42,6 +50,12 @@ > {{ loadError }} + + {{ saveError }} + (draggedIndex = index)" @drag-end="() => (draggedIndex = null)" @@ -73,12 +87,6 @@ placeholder="Titel" class="widget-edit-input" /> - wt.endpoint === w.endpoint); + return t ? t.endpoint : w.endpoint; + }, /** Counter für EP: wievieltes Widget mit gleichem Endpoint (0, 1, 2, …), damit z. B. News nicht doppelt. */ widgetRequestCounter(index) { - const endpoint = this.widgets[index]?.endpoint; - if (endpoint == null) return undefined; + const endpoint = this.effectiveEndpoint(this.widgets[index]); + if (!endpoint) return undefined; let count = 0; for (let i = 0; i < index; i++) { - if (this.widgets[i].endpoint === endpoint) count++; + if (this.effectiveEndpoint(this.widgets[i]) === endpoint) count++; } return count; }, @@ -177,33 +192,52 @@ export default { } }, async saveConfig() { + this.saveError = null; try { - await apiClient.put('/api/dashboard/config', { widgets: this.widgets }); + const payload = this.widgets.map(w => ({ + id: w.id, + title: w.title, + endpoint: this.effectiveEndpoint(w) + })); + await apiClient.put('/api/dashboard/config', { widgets: payload }); } catch (e) { console.error('Dashboard speichern fehlgeschlagen:', e); + this.saveError = e.response?.data?.error || e.message || 'Dashboard konnte nicht gespeichert werden.'; } }, - onSelectWidgetType() { + addWidgetFromType(wt) { + this.widgets.push({ + id: generateId(), + title: wt.label, + endpoint: wt.endpoint + }); + }, + async onSelectWidgetType() { const id = this.selectedWidgetTypeId; if (!id) return; const wt = this.widgetTypeOptions.find(w => String(w.id) === String(id)); if (wt) { - this.widgets.push({ - id: generateId(), - title: wt.label, - endpoint: wt.endpoint - }); - this.saveConfig(); + this.addWidgetFromType(wt); + await this.saveConfig(); } this.selectedWidgetTypeId = ''; }, - removeWidget(index) { - this.widgets.splice(index, 1); - this.saveConfig(); + async addSameWidgetType() { + const id = this.selectedWidgetTypeId; + if (!id) return; + const wt = this.widgetTypeOptions.find(w => String(w.id) === String(id)); + if (wt) { + this.addWidgetFromType(wt); + await this.saveConfig(); + } }, - doneEditing() { + async removeWidget(index) { + this.widgets.splice(index, 1); + await this.saveConfig(); + }, + async doneEditing() { this.editMode = false; - this.saveConfig(); + await this.saveConfig(); }, setDropTarget(index) { this.dragOverIndex = index; @@ -214,7 +248,7 @@ export default { onGridDragover() { this.dragOverIndex = this.widgets.length; }, - onGridDrop() { + async onGridDrop() { if (this.draggedIndex == null) return; const from = this.draggedIndex; const to = this.widgets.length; @@ -227,9 +261,9 @@ export default { this.widgets.splice(to, 0, item); this.draggedIndex = null; this.dragOverIndex = null; - this.saveConfig(); + await this.saveConfig(); }, - onDrop(toIndex) { + async onDrop(toIndex) { if (this.draggedIndex == null) return; const from = this.draggedIndex; const to = toIndex; @@ -242,7 +276,7 @@ export default { this.widgets.splice(to, 0, item); this.draggedIndex = null; this.dragOverIndex = null; - this.saveConfig(); + await this.saveConfig(); } } }; @@ -297,6 +331,22 @@ export default { .widget-add-row { display: flex; align-items: center; + gap: 10px; +} + +.btn-add-again { + padding: 8px 14px; + border-radius: 4px; + border: 1px solid var(--color-text-secondary); + background: #fff; + color: var(--color-text-primary); + font-size: 0.9rem; + cursor: pointer; +} + +.btn-add-again:hover { + background: var(--color-primary-orange-light); + border-color: var(--color-primary-orange); } .widget-type-select { @@ -361,11 +411,6 @@ export default { font-size: 0.9rem; } -.widget-edit-endpoint { - font-family: monospace; - font-size: 0.85rem; -} - .btn-remove { align-self: flex-start; padding: 6px 12px;