Files
yourpart3/frontend/src/views/home/LoggedInView.vue
Torsten Schulz (local) 6d9d69dc10
All checks were successful
Deploy to production / deploy (push) Successful in 3m0s
feat(localization): expand language support and enhance UI for user settings
- Added support for additional UI locales including Cebuano and Spanish, improving accessibility for a broader user base.
- Updated language selection components in the AppHeader and SettingsWidget to reflect new language options, enhancing user experience.
- Enhanced localization of various UI elements across components, ensuring consistent language representation and improved user engagement.
- Implemented logic to synchronize user language preferences with backend settings, providing a seamless experience when changing languages.
2026-04-02 07:54:44 +02:00

636 lines
17 KiB
Vue

<template>
<div class="home-logged-in">
<section class="dashboard-hero surface-card">
<div class="dashboard-hero__copy">
<span class="dashboard-kicker">{{ $t('home.dashboard.kicker') }}</span>
<h1>{{ $t('home.dashboard.title') }}</h1>
<p class="dashboard-subtitle">
{{ $t('home.dashboard.subtitle') }}
</p>
</div>
<div class="dashboard-toolbar surface-card">
<button
v-if="!editMode"
type="button"
class="btn-edit"
@click="editMode = true"
>
{{ $t('home.dashboard.edit') }}
</button>
<template v-else>
<div class="widget-add-row">
<select
v-model="selectedWidgetTypeId"
class="widget-type-select"
@change="onSelectWidgetType"
>
<option value="">{{ $t('home.dashboard.addWidget') }}</option>
<option
v-for="wt in widgetTypeOptions"
:key="wt.id"
:value="wt.id"
>
{{ wt.label }}
</option>
</select>
<button
v-if="selectedWidgetTypeId"
type="button"
class="btn-add-again"
@click="addSameWidgetType"
>
{{ $t('home.dashboard.addAgain') }}
</button>
</div>
<button type="button" class="btn-done" @click="doneEditing">
{{ $t('home.dashboard.done') }}
</button>
</template>
</div>
</section>
<section class="dashboard-overview">
<article class="overview-card surface-card">
<span class="overview-card__label">{{ $t('home.dashboard.overview.activeWidgetsLabel') }}</span>
<strong>{{ widgets.length }}</strong>
<p>{{ $t('home.dashboard.overview.activeWidgetsText') }}</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">{{ $t('home.dashboard.overview.availableModulesLabel') }}</span>
<strong>{{ widgetTypeOptions.length }}</strong>
<p>{{ $t('home.dashboard.overview.availableModulesText') }}</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">{{ $t('home.dashboard.overview.editModeLabel') }}</span>
<strong>{{ editMode ? $t('home.dashboard.overview.editModeActive') : $t('home.dashboard.overview.editModeInactive') }}</strong>
<p>{{ editMode ? $t('home.dashboard.overview.editModeActiveText') : $t('home.dashboard.overview.editModeInactiveText') }}</p>
</article>
</section>
<div
v-if="loadError"
class="dashboard-message dashboard-error"
>
{{ loadError }}
</div>
<div
v-if="saveError"
class="dashboard-message dashboard-error"
>
{{ saveError }}
</div>
<div
v-else
class="dashboard-shell"
>
<div class="dashboard-shell__header">
<div>
<h2>{{ $t('home.dashboard.sectionTitle') }}</h2>
<p>{{ $t('home.dashboard.sectionIntro') }}</p>
</div>
</div>
<div
ref="dashboardGridRef"
class="dashboard-grid"
@dragover.prevent
@drop.prevent="onAnyDrop($event)"
>
<template v-for="(w, index) in widgets" :key="w.id">
<div
class="dashboard-grid-cell"
:data-drop-index="index"
:class="{
'drop-target': dragOverIndex === index && draggedIndex !== index,
'drag-source': draggedIndex === index
}"
@dragover.prevent="() => setDropTarget(index)"
@dragleave="clearDropTarget"
>
<DashboardWidget
v-if="!editMode"
:widget-id="w.id"
:title="w.title"
:endpoint="effectiveEndpoint(w)"
:request-counter="widgetRequestCounter(index)"
@drag-start="() => (draggedIndex = index)"
@drag-end="() => onDragEnd()"
/>
<div v-else class="dashboard-widget-edit">
<div class="widget-edit-fields">
<input
v-model="w.title"
type="text"
:placeholder="$t('home.dashboard.widgetTitlePlaceholder')"
class="widget-edit-input"
/>
</div>
<button
type="button"
class="btn-remove"
:title="$t('home.dashboard.removeWidget')"
@click="removeWidget(index)"
>
{{ $t('home.dashboard.remove') }}
</button>
</div>
</div>
</template>
</div>
</div>
<div v-if="widgets.length === 0 && !loading" class="dashboard-empty">
<p>{{ $t('home.dashboard.empty') }}</p>
</div>
<div class="actions">
<!-- Platz für weitere Aktionen -->
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import DashboardWidget from '@/components/DashboardWidget.vue';
function generateId() {
return typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `w-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export default {
name: 'HomeLoggedInView',
components: { DashboardWidget },
computed: {
widgetTypeOptions() {
if (this.availableWidgets.length > 0) return this.availableWidgets;
return [{ id: 'default', label: this.$t('home.dashboard.defaultAppointmentsWidget'), endpoint: '/api/termine' }];
}
},
data() {
return {
widgets: [],
availableWidgets: [],
selectedWidgetTypeId: '',
loading: true,
loadError: null,
saveError: null,
editMode: false,
draggedIndex: null,
dragOverIndex: null
};
},
mounted() {
this.loadConfig();
this.loadAvailableWidgets();
},
methods: {
getLocalizedWidgetLabel(endpoint, fallbackLabel = '') {
const key = {
'/api/termine': 'home.dashboard.widgetLabels.appointments',
'/api/falukant/dashboard-widget': 'home.dashboard.widgetLabels.falukant',
'/api/news': 'home.dashboard.widgetLabels.news',
'/api/calendar/widget/birthdays': 'home.dashboard.widgetLabels.birthdays',
'/api/calendar/widget/upcoming': 'home.dashboard.widgetLabels.upcoming',
'/api/calendar/widget/mini': 'home.dashboard.widgetLabels.calendar'
}[endpoint];
return key ? this.$t(key) : fallbackLabel;
},
normalizeWidgetType(widgetType) {
return {
...widgetType,
label: this.getLocalizedWidgetLabel(widgetType?.endpoint, widgetType?.label || '')
};
},
normalizeWidgetConfig(widget) {
const localizedLabel = this.getLocalizedWidgetLabel(widget?.endpoint, '');
const title = String(widget?.title || '').trim();
const knownDefaultLabels = [
'Termine',
'Falukant',
'News',
'Geburtstage',
'Nächste Termine',
'Kalender'
];
return {
...widget,
title: !title || knownDefaultLabels.includes(title)
? (localizedLabel || title)
: title
};
},
/** 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.effectiveEndpoint(this.widgets[index]);
if (!endpoint) return undefined;
let count = 0;
for (let i = 0; i < index; i++) {
if (this.effectiveEndpoint(this.widgets[i]) === endpoint) count++;
}
return count;
},
async loadAvailableWidgets() {
try {
const { data } = await apiClient.get('/api/dashboard/widgets');
this.availableWidgets = Array.isArray(data) ? data.map(widget => this.normalizeWidgetType(widget)) : [];
} catch (e) {
this.availableWidgets = [];
}
},
async loadConfig() {
this.loading = true;
this.loadError = null;
try {
const { data } = await apiClient.get('/api/dashboard/config');
let list = Array.isArray(data?.widgets) ? [...data.widgets] : [];
if (list.length === 0) {
list = [{ id: generateId(), title: this.$t('home.dashboard.defaultAppointmentsWidget'), endpoint: '/api/termine' }];
this.widgets = list;
await this.saveConfig();
} else {
this.widgets = list.map(widget => this.normalizeWidgetConfig(widget));
}
} catch (e) {
this.loadError = e.response?.data?.error || e.message || this.$t('home.dashboard.loadError');
} finally {
this.loading = false;
}
},
async saveConfig() {
this.saveError = null;
try {
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 || this.$t('home.dashboard.saveError');
}
},
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.addWidgetFromType(wt);
await this.saveConfig();
}
this.selectedWidgetTypeId = '';
},
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();
}
},
async removeWidget(index) {
this.widgets.splice(index, 1);
await this.saveConfig();
},
async doneEditing() {
this.editMode = false;
await this.saveConfig();
},
setDropTarget(index) {
if (this.draggedIndex !== null && this.draggedIndex !== index) {
this.dragOverIndex = index;
}
},
clearDropTarget() {
// Nicht sofort löschen, damit der Indikator sichtbar bleibt
// Wird beim Drop oder Drag-End gelöscht
},
onGridDragover() {
if (this.draggedIndex !== null) {
this.dragOverIndex = this.widgets.length;
}
},
/** Ein zentraler Drop-Handler: Ziel-Index wird aus der Mausposition ermittelt (nicht aus Event-Target). */
async onAnyDrop(e) {
if (this.draggedIndex == null) {
this.dragOverIndex = null;
return;
}
const gridEl = this.$refs.dashboardGridRef;
const x = e.clientX;
const y = e.clientY;
let to = null;
if (gridEl) {
let el = document.elementFromPoint(x, y);
while (el && el !== document.body) {
if (el === gridEl) break;
const idx = el.getAttribute?.('data-drop-index');
if (idx != null && idx !== '') {
to = parseInt(idx, 10);
if (!Number.isNaN(to)) break;
}
el = el.parentElement;
}
}
if (to == null) to = this.dragOverIndex != null ? this.dragOverIndex : this.widgets.length;
const from = this.draggedIndex;
if (from === to || to < 0 || to > this.widgets.length) {
this.draggedIndex = null;
this.dragOverIndex = null;
return;
}
const item = this.widgets.splice(from, 1)[0];
this.widgets.splice(to, 0, item);
this.draggedIndex = null;
this.dragOverIndex = null;
await this.saveConfig();
},
onDragEnd() {
this.draggedIndex = null;
this.dragOverIndex = null;
}
}
};
</script>
<style scoped>
.home-logged-in {
max-width: var(--content-max-width);
margin: 0 auto;
padding: 8px 0 24px;
}
.dashboard-hero {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 20px;
padding: 26px;
margin-bottom: 18px;
background:
radial-gradient(circle at top right, rgba(248, 162, 43, 0.18), transparent 28%),
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(250, 243, 233, 0.98) 100%);
}
.dashboard-hero__copy {
max-width: 640px;
}
.dashboard-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboard-hero h1 {
margin: 0 0 8px;
}
.dashboard-subtitle {
color: var(--color-text-secondary);
margin: 0;
max-width: 58ch;
}
.dashboard-overview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.overview-card {
padding: 18px 20px;
}
.overview-card__label {
display: inline-block;
margin-bottom: 12px;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.overview-card strong {
display: block;
margin-bottom: 8px;
font-size: 1.9rem;
line-height: 1;
color: var(--color-text-primary);
}
.overview-card p {
margin: 0;
color: var(--color-text-secondary);
}
.dashboard-toolbar {
display: flex;
align-items: center;
align-self: flex-start;
flex-wrap: wrap;
gap: 10px;
padding: 14px;
min-width: 300px;
background: rgba(255, 255, 255, 0.72);
}
.btn-edit,
.btn-done {
min-height: 40px;
}
.btn-edit:hover,
.btn-done:hover {
color: #2b1f14;
}
.widget-add-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.btn-add-again {
min-height: 40px;
background: rgba(255, 255, 255, 0.78);
border-color: var(--color-border-strong);
box-shadow: none;
}
.btn-add-again:hover {
background: rgba(255, 255, 255, 0.96);
}
.widget-type-select {
min-width: 180px;
}
.dashboard-message {
padding: 16px 18px;
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.dashboard-error {
background: rgba(177, 59, 53, 0.12);
color: #7a241f;
border: 1px solid rgba(177, 59, 53, 0.18);
}
.dashboard-shell {
padding: 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background:
linear-gradient(180deg, rgba(255, 252, 247, 0.94) 0%, rgba(248, 241, 231, 0.96) 100%);
box-shadow: var(--shadow-soft);
}
.dashboard-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.dashboard-shell__header h2 {
margin: 0 0 4px;
font-size: 1.4rem;
}
.dashboard-shell__header p {
margin: 0;
color: var(--color-text-secondary);
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-auto-rows: 220px;
gap: 18px;
}
.dashboard-grid-cell {
min-height: 0;
display: flex;
flex-direction: column;
transition: all 0.2s ease;
}
.dashboard-grid-cell > * {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.dashboard-grid-cell.drop-target {
outline: 2px dashed rgba(248, 162, 43, 0.82);
outline-offset: 4px;
border-radius: var(--radius-md);
}
.dashboard-grid-cell.drag-source {
opacity: 0.5;
}
.dashboard-widget-edit {
min-height: 200px;
padding: 16px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: var(--shadow-soft);
}
.widget-edit-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.btn-remove {
align-self: flex-start;
min-height: 36px;
background: rgba(177, 59, 53, 0.12);
color: #7a241f;
border-color: rgba(177, 59, 53, 0.18);
box-shadow: none;
}
.btn-remove:hover {
background: rgba(177, 59, 53, 0.18);
}
.dashboard-empty {
padding: 32px;
text-align: center;
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.72);
border-radius: var(--radius-lg);
border: 1px dashed var(--color-border-strong);
box-shadow: var(--shadow-soft);
}
.actions {
margin-top: 30px;
}
@media (max-width: 960px) {
.home-logged-in {
padding-bottom: 18px;
}
.dashboard-hero {
flex-direction: column;
padding: 20px;
}
.dashboard-toolbar {
width: 100%;
min-width: 0;
}
.dashboard-overview {
grid-template-columns: 1fr;
}
.dashboard-shell {
padding: 16px;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
}
</style>