Refactor dashboard widget management: Update dashboardService to handle user-specific widget configurations with create and update logic. Enhance LoggedInView to support adding the same widget type and display error messages for save operations. Ensure effective endpoint handling for widgets and improve UI interactions.

This commit is contained in:
Torsten Schulz (local)
2026-01-30 07:31:38 +01:00
parent 39ac149430
commit 4779a6e4af
4 changed files with 114 additions and 48 deletions

View File

@@ -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 };
}
}

View File

@@ -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 };

View File

@@ -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.

View File

@@ -28,6 +28,14 @@
{{ wt.label }}
</option>
</select>
<button
v-if="selectedWidgetTypeId"
type="button"
class="btn-add-again"
@click="addSameWidgetType"
>
Nochmal hinzufügen
</button>
</div>
<button type="button" class="btn-done" @click="doneEditing">
Fertig
@@ -42,6 +50,12 @@
>
{{ loadError }}
</div>
<div
v-if="saveError"
class="dashboard-message dashboard-error"
>
{{ saveError }}
</div>
<div
v-else
class="dashboard-grid"
@@ -60,7 +74,7 @@
v-if="!editMode"
:widget-id="w.id"
:title="w.title"
:endpoint="w.endpoint"
:endpoint="effectiveEndpoint(w)"
:request-counter="widgetRequestCounter(index)"
@drag-start="() => (draggedIndex = index)"
@drag-end="() => (draggedIndex = null)"
@@ -73,12 +87,6 @@
placeholder="Titel"
class="widget-edit-input"
/>
<input
v-model="w.endpoint"
type="text"
placeholder="Endpoint (z. B. /api/termine)"
class="widget-edit-input widget-edit-endpoint"
/>
</div>
<button
type="button"
@@ -129,6 +137,7 @@ export default {
selectedWidgetTypeId: '',
loading: true,
loadError: null,
saveError: null,
editMode: false,
draggedIndex: null,
dragOverIndex: null
@@ -139,13 +148,19 @@ export default {
this.loadAvailableWidgets();
},
methods: {
/** Endpoint aus Widget-Typ (anhand gespeichertem endpoint gematcht), sonst w.endpoint. */
effectiveEndpoint(w) {
if (!w?.endpoint) return '';
const t = this.availableWidgets.find(wt => 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;