feat(localization): expand language support and enhance UI for user settings
All checks were successful
Deploy to production / deploy (push) Successful in 3m0s

- 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.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 07:54:44 +02:00
parent ac5d436a36
commit 6d9d69dc10
72 changed files with 1792 additions and 343 deletions

View File

@@ -4,10 +4,12 @@
<div class="footer-system">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
<span>{{ $t('appShell.footer.systemLabel') }}</span>
</button>
<span class="footer-caption">
{{ openDialogs.length === 0 ? 'Keine offenen Dialoge' : `${openDialogs.length} Fenster aktiv` }}
{{ openDialogs.length === 0
? $t('appShell.footer.noOpenDialogs')
: $t('appShell.footer.activeWindows', { count: openDialogs.length }) }}
</span>
</div>
@@ -23,7 +25,7 @@
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
<span v-if="openDialogs.length === 0" class="window-bar__empty">System bereit</span>
<span v-if="openDialogs.length === 0" class="window-bar__empty">{{ $t('appShell.footer.systemReady') }}</span>
</div>
<div class="static-block">
@@ -72,7 +74,7 @@ export default {
},
// Daemon WebSocket deaktiviert - diese Funktionen sind nicht mehr verfügbar
async showFalukantDaemonStatus() {
showInfo(this, 'Der Systemstatus ist in dieser Ansicht derzeit nicht direkt verfügbar.');
showInfo(this, this.$t('appShell.footer.systemStatusUnavailable'));
},
handleDaemonMessage() {
// Status-Events werden hier bewusst nicht verarbeitet.

View File

@@ -5,21 +5,34 @@
<div class="logo"><img src="/images/logos/logo.png" alt="YourPart" /></div>
<div class="brand-copy">
<strong>YourPart</strong>
<span>Community-Plattform</span>
<span>{{ $t('appShell.header.tagline') }}</span>
</div>
</div>
<div class="header-meta">
<div class="header-meta__context">
<span class="header-pill">Beta</span>
<span class="header-pill">{{ $t('appShell.header.beta') }}</span>
<label class="header-lang">
<span class="header-lang__label">{{ $t('appShell.header.language') }}</span>
<select
class="header-lang__select"
:aria-label="$t('appShell.header.language')"
:value="language"
@change="onUiLanguageChange($event.target.value)"
>
<option v-for="opt in uiLocaleOptions" :key="opt.value" :value="opt.value">
{{ $t(opt.labelTr) }}
</option>
</select>
</label>
</div>
<div class="connection-status" v-if="isLoggedIn">
<div class="status-indicator" :class="backendStatusClass">
<span class="status-dot"></span>
<span class="status-text">Backend</span>
<span class="status-text">{{ $t('appShell.header.backend') }}</span>
</div>
<div class="status-indicator" :class="daemonStatusClass">
<span class="status-dot"></span>
<span class="status-text">Daemon</span>
<span class="status-text">{{ $t('appShell.header.daemon') }}</span>
</div>
</div>
</div>
@@ -29,11 +42,22 @@
<script>
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'AppHeader',
data() {
return {
uiLocaleOptions: [
{ value: 'de', labelTr: 'settings.personal.language.de' },
{ value: 'en', labelTr: 'settings.personal.language.en' },
{ value: 'ceb', labelTr: 'settings.personal.language.ceb' },
{ value: 'es', labelTr: 'settings.personal.language.es' },
],
};
},
computed: {
...mapGetters(['isLoggedIn', 'connectionStatus', 'daemonConnectionStatus']),
...mapGetters(['isLoggedIn', 'user', 'language', 'connectionStatus', 'daemonConnectionStatus']),
backendStatusClass() {
return {
'status-connected': this.connectionStatus === 'connected',
@@ -50,7 +74,40 @@ export default {
'status-error': this.daemonConnectionStatus === 'error'
};
}
}
},
methods: {
async onUiLanguageChange(code) {
const supported = ['de', 'en', 'ceb', 'es'];
if (!supported.includes(code)) {
return;
}
await this.$store.dispatch('setLanguage', code);
if (!this.isLoggedIn || !this.user?.id) {
return;
}
try {
const { data } = await apiClient.post('/api/settings/filter', {
userid: this.user.id,
type: 'personal',
});
const langRow = data.find((s) => s.name === 'language');
if (!langRow?.options?.length) {
return;
}
const opt = langRow.options.find((o) => o.value === code);
if (!opt) {
return;
}
await apiClient.post('/api/settings/update', {
userid: this.user.id,
settingId: langRow.id,
value: opt.id,
});
} catch (err) {
console.warn('AppHeader: profile language could not be synced', err);
}
},
},
};
</script>
@@ -144,6 +201,38 @@ export default {
color: #8a5411;
}
.header-lang {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 0.72rem;
color: rgba(95, 75, 57, 0.85);
}
.header-lang__label {
font-weight: 600;
white-space: nowrap;
}
.header-lang__select {
min-width: 7.5rem;
max-width: 11rem;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(93, 64, 55, 0.18);
background: rgba(255, 255, 255, 0.85);
color: #3a2a1b;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
}
.header-lang__select:focus {
outline: 2px solid rgba(248, 162, 43, 0.45);
outline-offset: 1px;
}
.connection-status {
display: flex;
align-items: center;

View File

@@ -5,12 +5,12 @@
:data-widget-id="widgetId"
>
<header class="dashboard-widget__titlebar">
<span class="dashboard-widget__drag-handle" title="Verschieben" draggable="true" @dragstart="onDragStart" @dragend="onDragEnd"></span>
<span class="dashboard-widget__drag-handle" :title="$t('widgets.dashboard.dragHandle')" draggable="true" @dragstart="onDragStart" @dragend="onDragEnd"></span>
<span class="dashboard-widget__title">{{ title }}</span>
<slot name="title-actions"></slot>
</header>
<div class="dashboard-widget__frame">
<div v-if="loading" class="dashboard-widget__state">Laden</div>
<div v-if="loading" class="dashboard-widget__state">{{ $t('widgets.dashboard.loading') }}</div>
<div v-else-if="error" class="dashboard-widget__state dashboard-widget__error">{{ error }}</div>
<div v-else class="dashboard-widget__body">
<component :is="widgetComponent" :data="data" />

View File

@@ -9,7 +9,7 @@
{{ message }}
</div>
<div class="dialog-footer">
<button @click="close()" class="dialog-button">Ok</button>
<button @click="close()" class="dialog-button">{{ $t('general.ok') }}</button>
</div>
</div>
</div>

View File

@@ -103,10 +103,6 @@ export default {
type: String,
required: true
}
},
data: {
settings: [],
possibleVisibilities: [],
},
computed: {
...mapGetters(['user']),
@@ -170,6 +166,14 @@ export default {
settingId: settingId,
value: value
});
if (setting?.name === 'language' && Array.isArray(setting.options)) {
const opt = setting.options.find((o) => String(o.id) === String(value));
const code = opt?.value;
const supported = ['de', 'en', 'ceb', 'es'];
if (code && supported.includes(code)) {
this.$store.dispatch('setLanguage', code);
}
}
this.fetchSettings();
} catch (err) {
console.error('Error updating setting:', err);
@@ -238,6 +242,7 @@ export default {
data() {
return {
settings: [],
possibleVisibilities: [],
userEmail: "",
userUsername: "",
};

View File

@@ -1,10 +1,10 @@
<template>
<div class="termine-widget">
<h2>📅 Termine</h2>
<div v-if="loading" class="loading">Lade Termine...</div>
<h2>{{ $t('widgets.appointments.title') }}</h2>
<div v-if="loading" class="loading">{{ $t('widgets.appointments.loading') }}</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="termine.length === 0" class="no-termine">
Keine bevorstehenden Termine
{{ $t('widgets.appointments.empty') }}
</div>
<div v-else class="termine-list">
<div v-for="termin in termine" :key="termin.datum + termin.titel" class="termin-item">
@@ -50,7 +50,7 @@ export default {
this.termine = response.data;
} catch (error) {
console.error('Error loading termine:', error);
this.error = 'Termine konnten nicht geladen werden';
this.error = this.$t('widgets.appointments.loadError');
} finally {
this.loading = false;
}
@@ -64,7 +64,16 @@ export default {
month: 'long',
day: 'numeric'
};
return date.toLocaleDateString('de-DE', options);
return date.toLocaleDateString(this.getDateLocale(), options);
},
getDateLocale() {
const locale = this.$i18n?.locale;
return {
de: 'de-DE',
en: 'en-GB',
es: 'es-ES',
ceb: 'fil-PH'
}[locale] || 'de-DE';
}
}
};

View File

@@ -10,7 +10,7 @@
<th>{{ $t('falukant.branch.sale.quality') }}</th>
<th>{{ $t('falukant.branch.sale.quantity') }}</th>
<th>{{ $t('falukant.branch.sale.sell') }}</th>
<th>Bessere Preise</th>
<th>{{ $t('falukant.branch.revenue.betterPrices') }}</th>
</tr>
</thead>
<tbody>

View File

@@ -165,7 +165,7 @@
});
} catch (err) {
console.error(err);
showError(this, 'Fehler beim Kaufen eines Teils der Lagerkapazität.');
showError(this, this.$t('falukant.branch.storage.buyPartialError'));
}
remainingAmount -= toBuy;
}
@@ -186,7 +186,7 @@
.then(() => this.loadStorageData())
.catch(err => {
console.error(err);
showError(this, 'Fehler beim Verkaufen der Lagerkapazität.');
showError(this, this.$t('falukant.branch.storage.sellError'));
});
},
getCostOfType(labelTr) {

View File

@@ -9,19 +9,19 @@
<div class="birthday-info">
<div class="birthday-name">{{ birthday.username }}</div>
<div class="birthday-date">
<span v-if="birthday.daysUntil === 0" class="birthday-highlight">Heute!</span>
<span v-else-if="birthday.daysUntil === 1">Morgen</span>
<span v-if="birthday.daysUntil === 0" class="birthday-highlight">{{ $t('widgets.birthdays.today') }}</span>
<span v-else-if="birthday.daysUntil === 1">{{ $t('widgets.birthdays.tomorrow') }}</span>
<span v-else>{{ formatDate(birthday.nextDate) }}</span>
<span class="birthday-age">(wird {{ birthday.turningAge }})</span>
<span class="birthday-age">{{ $t('widgets.birthdays.turningAge', { age: birthday.turningAge }) }}</span>
</div>
</div>
<div v-if="birthday.daysUntil > 1" class="birthday-days">
{{ birthday.daysUntil }} Tage
{{ $t('widgets.birthdays.inDays', { count: birthday.daysUntil }) }}
</div>
</div>
</div>
<div v-else class="birthday-empty">
Keine Geburtstage von Freunden sichtbar
{{ $t('widgets.birthdays.empty') }}
</div>
</template>
@@ -37,10 +37,19 @@ export default {
}
},
methods: {
getDateLocale() {
const locale = this.$i18n?.locale;
return {
de: 'de-DE',
en: 'en-GB',
es: 'es-ES',
ceb: 'fil-PH'
}[locale] || 'de-DE';
},
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('de-DE', {
return d.toLocaleDateString(this.getDateLocale(), {
day: 'numeric',
month: 'short'
});

View File

@@ -21,7 +21,7 @@
</dd>
</template>
</dl>
<span v-else></span>
<span v-else>{{ $t('widgets.falukant.emptyValue') }}</span>
</template>
<script>
@@ -62,7 +62,7 @@ export default {
},
falukantDisplayName() {
const d = this.falukantData;
if (!d) return '—';
if (!d) return this.$t('widgets.falukant.emptyValue');
const titleKey = d.titleLabelTr;
const gender = d.gender;
const nameWithoutTitle = d.nameWithoutTitle ?? d.characterName;
@@ -71,11 +71,11 @@ export default {
const translatedTitle = this.$t(key);
if (translatedTitle !== key) return `${translatedTitle} ${nameWithoutTitle}`.trim();
}
return d.characterName || nameWithoutTitle || '—';
return d.characterName || nameWithoutTitle || this.$t('widgets.falukant.emptyValue');
},
falukantGenderLabel() {
const g = this.falukantData?.gender;
if (g == null || g === '') return '—';
if (g == null || g === '') return this.$t('widgets.falukant.emptyValue');
// Altersabhängige, (auf Wunsch) altertümlichere Bezeichnungen
const years = this._ageYearsFromWidgetValue(this.falukantData?.age);
@@ -93,9 +93,9 @@ export default {
},
falukantAgeLabel() {
const ageValue = this.falukantData?.age;
if (ageValue == null) return '—';
if (ageValue == null) return this.$t('widgets.falukant.emptyValue');
const years = this._ageYearsFromWidgetValue(ageValue);
if (years == null) return '—';
if (years == null) return this.$t('widgets.falukant.emptyValue');
return `${years} ${this.$t('falukant.overview.metadata.years')}`;
}
},
@@ -145,8 +145,17 @@ export default {
},
formatMoney(value) {
const n = Number(value);
if (Number.isNaN(n)) return '—';
return n.toLocaleString('de-DE');
if (Number.isNaN(n)) return this.$t('widgets.falukant.emptyValue');
return n.toLocaleString(this.getNumberLocale());
},
getNumberLocale() {
const locale = this.$i18n?.locale;
return {
de: 'de-DE',
en: 'en-GB',
es: 'es-ES',
ceb: 'fil-PH'
}[locale] || 'de-DE';
}
}
};

View File

@@ -30,21 +30,32 @@ export default {
fallbackText() {
if (this.data == null) return '';
if (Array.isArray(this.data)) {
return this.data.length === 0 ? 'Keine Einträge' : `(${this.data.length} Einträge)`;
return this.data.length === 0
? this.$t('widgets.list.noEntries')
: this.$t('widgets.list.entriesCount', { count: this.data.length });
}
if (typeof this.data === 'object') {
const keys = Object.keys(this.data);
return keys.length === 0 ? '—' : `(${keys.length} Felder)`;
return keys.length === 0 ? '—' : this.$t('widgets.list.fieldsCount', { count: keys.length });
}
return String(this.data);
}
},
methods: {
getDateLocale() {
const locale = this.$i18n?.locale;
return {
de: 'de-DE',
en: 'en-GB',
es: 'es-ES',
ceb: 'fil-PH'
}[locale] || 'de-DE';
},
formatDatum(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return String(dateStr);
return d.toLocaleDateString('de-DE', {
return d.toLocaleDateString(this.getDateLocale(), {
weekday: 'short',
day: 'numeric',
month: 'short',

View File

@@ -7,13 +7,13 @@
rel="noopener noreferrer"
class="dashboard-widget__news-title"
>
{{ article.title || '—' }}
{{ article.title || $t('widgets.news.emptyValue') }}
</a>
<span v-else class="dashboard-widget__title-text">{{ article.title || '—' }}</span>
<span v-else class="dashboard-widget__title-text">{{ article.title || $t('widgets.news.emptyValue') }}</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>
<span v-else>{{ $t('widgets.news.emptyValue') }}</span>
</template>
<script>
@@ -32,11 +32,20 @@ export default {
}
},
methods: {
getDateLocale() {
const locale = this.$i18n?.locale;
return {
de: 'de-DE',
en: 'en-GB',
es: 'es-ES',
ceb: 'fil-PH'
}[locale] || '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', {
return d.toLocaleDateString(this.getDateLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric',

View File

@@ -14,16 +14,16 @@
<div class="upcoming-date">
{{ formatDate(event.datum) }}
<span v-if="event.startTime && !event.allDay" class="upcoming-time">
{{ event.startTime }} Uhr
{{ $t('widgets.upcoming.timeAt', { time: event.startTime }) }}
</span>
<span v-if="event.allDay" class="upcoming-allday">Ganztägig</span>
<span v-if="event.allDay" class="upcoming-allday">{{ $t('widgets.upcoming.allDay') }}</span>
</div>
<div v-if="event.beschreibung" class="upcoming-desc">{{ event.beschreibung }}</div>
</div>
</div>
</div>
<div v-else class="upcoming-empty">
Keine anstehenden Termine
{{ $t('widgets.upcoming.empty') }}
</div>
</template>
@@ -50,6 +50,15 @@ export default {
}
},
methods: {
getDateLocale() {
const locale = this.$i18n?.locale;
return {
de: 'de-DE',
en: 'en-GB',
es: 'es-ES',
ceb: 'fil-PH'
}[locale] || 'de-DE';
},
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
@@ -58,13 +67,13 @@ export default {
tomorrow.setDate(tomorrow.getDate() + 1);
if (d.toDateString() === today.toDateString()) {
return 'Heute';
return this.$t('widgets.upcoming.today');
}
if (d.toDateString() === tomorrow.toDateString()) {
return 'Morgen';
return this.$t('widgets.upcoming.tomorrow');
}
return d.toLocaleDateString('de-DE', {
return d.toLocaleDateString(this.getDateLocale(), {
weekday: 'short',
day: 'numeric',
month: 'short'