Refactor DashboardWidget to use dynamic widget components: Replace static slot content with a dynamic component rendering based on the endpoint prop. This change simplifies the widget structure and enhances flexibility by allowing different widget types to be displayed. Additionally, update error handling to provide more specific error messages.

This commit is contained in:
Torsten Schulz (local)
2026-01-30 13:42:22 +01:00
parent 752686e3e1
commit 7d2a33b3ec
4 changed files with 295 additions and 204 deletions

View File

@@ -13,43 +13,7 @@
<div v-if="loading" class="dashboard-widget__state">Laden</div>
<div v-else-if="error" class="dashboard-widget__state dashboard-widget__error">{{ error }}</div>
<div v-else class="dashboard-widget__body">
<slot :data="data">
<template v-if="newsDataSingleItem">
<article class="dashboard-widget__news-single">
<a v-if="newsDataSingleItem.link" :href="newsDataSingleItem.link" target="_blank" rel="noopener noreferrer" class="dashboard-widget__news-title">{{ newsDataSingleItem.title || '' }}</a>
<span v-else class="dashboard-widget__title-text">{{ newsDataSingleItem.title || '—' }}</span>
<span v-if="newsDataSingleItem.pubDate" class="dashboard-widget__date">{{ formatNewsDate(newsDataSingleItem.pubDate) }}</span>
<p v-if="newsDataSingleItem.description" class="dashboard-widget__desc">{{ newsDataSingleItem.description }}</p>
</article>
</template>
<template v-else-if="falukantData">
<dl class="dashboard-widget__falukant">
<dt>{{ $t('falukant.overview.metadata.name') }}</dt>
<dd>{{ falukantDisplayName }}</dd>
<dt>{{ $t('falukant.create.gender') }}</dt>
<dd class="falukant-gender">{{ falukantGenderLabel }}</dd>
<dt>{{ $t('falukant.overview.metadata.age') }}</dt>
<dd class="falukant-age">{{ falukantAgeLabel }}</dd>
<dt>{{ $t('falukant.overview.metadata.money') }}</dt>
<dd>{{ formatMoney(falukantData.money) }}</dd>
<dt>{{ $t('falukant.messages.title') }}</dt>
<dd>{{ falukantData.unreadNotificationsCount }}</dd>
<dt>{{ $t('falukant.statusbar.children') }}</dt>
<dd>{{ falukantData.childrenCount }}</dd>
</dl>
</template>
<template v-else-if="dataList.length">
<ul class="dashboard-widget__list">
<li v-for="(item, i) in dataList" :key="i" class="dashboard-widget__list-item">
<span v-if="item.datum" class="dashboard-widget__date">{{ formatDatum(item.datum) }}</span>
<span v-if="item.titel" class="dashboard-widget__title-text">{{ item.titel }}</span>
<span v-else-if="item.label" class="dashboard-widget__title-text">{{ item.label }}</span>
<p v-if="item.beschreibung" class="dashboard-widget__desc">{{ item.beschreibung }}</p>
</li>
</ul>
</template>
<template v-else>{{ defaultContent }}</template>
</slot>
<component :is="widgetComponent" :data="data" />
</div>
</div>
</div>
@@ -58,16 +22,26 @@
<script>
import { mapState } from 'vuex';
import apiClient from '@/utils/axios.js';
import FalukantWidget from './widgets/FalukantWidget.vue';
import NewsWidget from './widgets/NewsWidget.vue';
import ListWidget from './widgets/ListWidget.vue';
function getWidgetComponent(endpoint) {
if (!endpoint || typeof endpoint !== 'string') return ListWidget;
const ep = endpoint.toLowerCase();
if (ep.includes('falukant')) return FalukantWidget;
if (ep.includes('news')) return NewsWidget;
return ListWidget;
}
export default {
name: 'DashboardWidget',
components: { FalukantWidget, NewsWidget, ListWidget },
props: {
widgetId: { type: String, required: true },
title: { type: String, required: true },
endpoint: { type: String, required: true },
/** Wenn true, wird nicht automatisch geladen (z. B. bei Bearbeitung). */
pauseFetch: { type: Boolean, default: false },
/** Wievieltes Widget dieser Art (0, 1, 2, …), wird als &counter=N ans EP gehängt (z. B. für News-Pagination). */
requestCounter: { type: Number, default: undefined }
},
emits: ['drag-start', 'drag-end'],
@@ -85,66 +59,8 @@ export default {
isFalukantWidget() {
return this.endpoint && String(this.endpoint).includes('falukant');
},
newsDataResults() {
const d = this.data;
if (d && typeof d === 'object' && Array.isArray(d.results)) return d.results;
return [];
},
/** Pro News-Widget wird nur ein Artikel angezeigt (erster der Seite; Counter sorgt für unterschiedliche Seiten). */
newsDataSingleItem() {
const list = this.newsDataResults;
return list.length ? list[0] : null;
},
falukantData() {
const d = this.data;
if (d && typeof d === 'object' && 'characterName' in d && 'money' in d) return d;
return null;
},
/** Anzeigename: Adelstitel aus falukant.titles.{gender}.{titleLabelTr} + Name. */
falukantDisplayName() {
const d = this.falukantData;
if (!d) return '—';
const titleKey = d.titleLabelTr;
const gender = d.gender;
const nameWithoutTitle = d.nameWithoutTitle ?? d.characterName;
if (titleKey && gender) {
const key = `falukant.titles.${gender}.${titleKey}`;
const translatedTitle = this.$t(key);
if (translatedTitle !== key) return `${translatedTitle} ${nameWithoutTitle}`.trim();
}
return d.characterName || nameWithoutTitle || '—';
},
falukantGenderLabel() {
const g = this.falukantData?.gender;
if (g == null || g === '') return '—';
const key = `falukant.create.${g}`;
const t = this.$t(key);
return t === key ? this.$t(`general.gender.${g}`) || g : t;
},
falukantAgeLabel() {
const ageValue = this.falukantData?.age;
if (ageValue == null) return '—';
const numAge = Number(ageValue);
// Backend gibt Tage zurück (calcAge verwendet differenceInDays)
// Wenn < 365 Tage: Tage anzeigen, sonst Jahre
return `${numAge} ${this.$t('falukant.overview.metadata.years')}`;
},
dataList() {
if (!Array.isArray(this.data) || this.data.length === 0) return [];
const first = this.data[0];
if (first !== null && typeof first === 'object') return this.data;
return [];
},
defaultContent() {
if (this.data == null) return '';
if (Array.isArray(this.data)) {
return this.data.length === 0 ? 'Keine Einträge' : `(${this.data.length} Einträge)`;
}
if (typeof this.data === 'object') {
const keys = Object.keys(this.data);
return keys.length === 0 ? '—' : `(${keys.length} Felder)`;
}
return String(this.data);
widgetComponent() {
return getWidgetComponent(this.endpoint);
}
},
watch: {
@@ -212,7 +128,7 @@ export default {
const res = await apiClient.get(path);
this.data = res.data;
} catch (e) {
this.error = e.response?.data?.message || e.message || 'Fehler beim Laden';
this.error = e.response?.data?.error || e.message || 'Fehler beim Laden';
} finally {
this.loading = false;
}
@@ -222,8 +138,6 @@ export default {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.widgetId);
e.dataTransfer.setData('application/x-widget-id', this.widgetId);
// Erstelle ein visuelles Drag-Ghost-Bild
const dragGhost = this.$el.cloneNode(true);
dragGhost.style.position = 'absolute';
dragGhost.style.top = '-9999px';
@@ -232,42 +146,18 @@ export default {
dragGhost.style.transform = 'rotate(-2deg)';
dragGhost.style.pointerEvents = 'none';
document.body.appendChild(dragGhost);
// Setze das Drag-Image mit Offset, damit es unter dem Cursor erscheint
const rect = this.$el.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
e.dataTransfer.setDragImage(dragGhost, offsetX, offsetY);
// Entferne das Ghost-Element nach kurzer Verzögerung
setTimeout(() => {
if (dragGhost.parentNode) {
dragGhost.parentNode.removeChild(dragGhost);
}
if (dragGhost.parentNode) dragGhost.parentNode.removeChild(dragGhost);
}, 0);
this.$emit('drag-start', { widgetId: this.widgetId, event: e });
},
onDragEnd() {
this.isDragging = false;
this.$emit('drag-end');
},
formatDatum(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return String(dateStr);
return d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
},
formatMoney(value) {
const n = Number(value);
if (Number.isNaN(n)) return '—';
return n.toLocaleString('de-DE');
},
formatNewsDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return String(dateStr);
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
}
};
@@ -286,6 +176,7 @@ export default {
overflow: hidden;
transition: opacity 0.2s ease, box-shadow 0.2s ease;
}
.dashboard-widget.is-dragging {
opacity: 0.4;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
@@ -312,6 +203,7 @@ export default {
font-size: 1rem;
line-height: 1;
}
.dashboard-widget__drag-handle:active {
cursor: grabbing;
}
@@ -337,6 +229,7 @@ export default {
text-align: center;
padding: 1rem;
}
.dashboard-widget__error {
color: #c92a2a;
}
@@ -345,81 +238,4 @@ export default {
font-size: 0.9rem;
color: #333;
}
.dashboard-widget__list {
list-style: none;
margin: 0;
padding: 0;
}
.dashboard-widget__list-item {
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.dashboard-widget__list-item:last-child {
border-bottom: none;
}
.dashboard-widget__date {
display: block;
font-size: 0.8rem;
color: #666;
margin-bottom: 2px;
}
.dashboard-widget__title-text {
font-weight: 600;
color: #333;
}
.dashboard-widget__news-single {
margin: 0;
}
.dashboard-widget__news-title {
font-weight: 600;
color: var(--color-primary-orange);
text-decoration: none;
}
.dashboard-widget__news-title:hover {
text-decoration: underline;
color: var(--color-text-secondary);
}
.dashboard-widget__desc {
margin: 4px 0 0 0;
font-size: 0.85rem;
color: #555;
line-height: 1.4;
}
.dashboard-widget__falukant {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
align-items: baseline;
}
.dashboard-widget__falukant dt {
margin: 0;
font-weight: 600;
color: #555;
font-size: 0.85rem;
}
.dashboard-widget__falukant dd {
margin: 0;
font-size: 0.9rem;
}
.dashboard-widget__falukant dd.falukant-gender {
color: var(--color-text-secondary);
font-weight: 600;
}
.dashboard-widget__falukant dd.falukant-age {
color: #198754;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<dl v-if="falukantData" class="dashboard-widget__falukant">
<dt>{{ $t('falukant.overview.metadata.name') }}</dt>
<dd>{{ falukantDisplayName }}</dd>
<dt>{{ $t('falukant.create.gender') }}</dt>
<dd class="falukant-gender">{{ falukantGenderLabel }}</dd>
<dt>{{ $t('falukant.overview.metadata.age') }}</dt>
<dd class="falukant-age">{{ falukantAgeLabel }}</dd>
<dt>{{ $t('falukant.overview.metadata.money') }}</dt>
<dd>{{ formatMoney(falukantData.money) }}</dd>
<dt>{{ $t('falukant.messages.title') }}</dt>
<dd>{{ falukantData.unreadNotificationsCount }}</dd>
<dt>{{ $t('falukant.statusbar.children') }}</dt>
<dd>{{ falukantData.childrenCount }}</dd>
</dl>
<span v-else></span>
</template>
<script>
export default {
name: 'FalukantWidget',
props: {
data: { type: Object, default: null }
},
computed: {
falukantData() {
const d = this.data;
if (d && typeof d === 'object' && 'characterName' in d && 'money' in d) return d;
return null;
},
falukantDisplayName() {
const d = this.falukantData;
if (!d) return '—';
const titleKey = d.titleLabelTr;
const gender = d.gender;
const nameWithoutTitle = d.nameWithoutTitle ?? d.characterName;
if (titleKey && gender) {
const key = `falukant.titles.${gender}.${titleKey}`;
const translatedTitle = this.$t(key);
if (translatedTitle !== key) return `${translatedTitle} ${nameWithoutTitle}`.trim();
}
return d.characterName || nameWithoutTitle || '—';
},
falukantGenderLabel() {
const g = this.falukantData?.gender;
if (g == null || g === '') return '—';
const key = `falukant.create.${g}`;
const t = this.$t(key);
return t === key ? this.$t(`general.gender.${g}`) || g : t;
},
falukantAgeLabel() {
const ageValue = this.falukantData?.age;
if (ageValue == null) return '—';
const numAge = Number(ageValue);
return `${numAge} ${this.$t('falukant.overview.metadata.years')}`;
}
},
methods: {
formatMoney(value) {
const n = Number(value);
if (Number.isNaN(n)) return '—';
return n.toLocaleString('de-DE');
}
}
};
</script>
<style scoped>
.dashboard-widget__falukant {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
align-items: baseline;
}
.dashboard-widget__falukant dt {
margin: 0;
font-weight: 600;
color: #555;
font-size: 0.85rem;
}
.dashboard-widget__falukant dd {
margin: 0;
font-size: 0.9rem;
}
.dashboard-widget__falukant dd.falukant-gender {
color: var(--color-text-secondary);
font-weight: 600;
}
.dashboard-widget__falukant dd.falukant-age {
color: #198754;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<ul v-if="items.length" class="dashboard-widget__list">
<li
v-for="(item, i) in items"
:key="i"
class="dashboard-widget__list-item"
>
<span v-if="item.datum" class="dashboard-widget__date">{{ formatDatum(item.datum) }}</span>
<span v-if="item.titel" class="dashboard-widget__title-text">{{ item.titel }}</span>
<span v-else-if="item.label" class="dashboard-widget__title-text">{{ item.label }}</span>
<p v-if="item.beschreibung" class="dashboard-widget__desc">{{ item.beschreibung }}</p>
</li>
</ul>
<span v-else>{{ fallbackText }}</span>
</template>
<script>
export default {
name: 'ListWidget',
props: {
data: { type: [Array, Object], default: null }
},
computed: {
items() {
if (!Array.isArray(this.data) || this.data.length === 0) return [];
const first = this.data[0];
if (first !== null && typeof first === 'object') return this.data;
return [];
},
fallbackText() {
if (this.data == null) return '';
if (Array.isArray(this.data)) {
return this.data.length === 0 ? 'Keine Einträge' : `(${this.data.length} Einträge)`;
}
if (typeof this.data === 'object') {
const keys = Object.keys(this.data);
return keys.length === 0 ? '—' : `(${keys.length} Felder)`;
}
return String(this.data);
}
},
methods: {
formatDatum(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return String(dateStr);
return d.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
}
};
</script>
<style scoped>
.dashboard-widget__list {
list-style: none;
margin: 0;
padding: 0;
}
.dashboard-widget__list-item {
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.dashboard-widget__list-item:last-child {
border-bottom: none;
}
.dashboard-widget__date {
display: block;
font-size: 0.8rem;
color: #666;
margin-bottom: 2px;
}
.dashboard-widget__title-text {
font-weight: 600;
color: #333;
}
.dashboard-widget__desc {
margin: 4px 0 0 0;
font-size: 0.85rem;
color: #555;
line-height: 1.4;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<article v-if="article" class="dashboard-widget__news-single">
<a
v-if="article.link"
:href="article.link"
target="_blank"
rel="noopener noreferrer"
class="dashboard-widget__news-title"
>
{{ article.title || '—' }}
</a>
<span v-else class="dashboard-widget__title-text">{{ article.title || '—' }}</span>
<span v-if="article.pubDate" class="dashboard-widget__date">{{ formatNewsDate(article.pubDate) }}</span>
<p v-if="article.description" class="dashboard-widget__desc">{{ article.description }}</p>
</article>
<span v-else></span>
</template>
<script>
export default {
name: 'NewsWidget',
props: {
data: { type: Object, default: null }
},
computed: {
article() {
const d = this.data;
if (d && typeof d === 'object' && Array.isArray(d.results) && d.results.length > 0) {
return d.results[0];
}
return null;
}
},
methods: {
formatNewsDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return String(dateStr);
return d.toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}
};
</script>
<style scoped>
.dashboard-widget__news-single {
margin: 0;
}
.dashboard-widget__news-title {
font-weight: 600;
color: var(--color-primary-orange);
text-decoration: none;
}
.dashboard-widget__news-title:hover {
text-decoration: underline;
color: var(--color-text-secondary);
}
.dashboard-widget__title-text {
font-weight: 600;
color: #333;
}
.dashboard-widget__date {
display: block;
font-size: 0.8rem;
color: #666;
margin-bottom: 2px;
}
.dashboard-widget__desc {
margin: 4px 0 0 0;
font-size: 0.85rem;
color: #555;
line-height: 1.4;
}
</style>