Files
yourpart3/frontend/src/components/falukant/MessagesDialog.vue
Torsten Schulz (local) d0dda70f52 feat(MessagesDialog, StatusBar, i18n): enhance character death notifications and UI updates
- Added support for displaying character death notifications with detailed information including age, region, and relationships in both German and English.
- Updated MessagesDialog to format additional parameters such as region, spouses, children, and lovers based on the locale.
- Enhanced StatusBar to show the character's name dynamically, improving user experience.
- Modified i18n files to include new notification messages for character deaths, ensuring accurate translations for both languages.
2026-03-30 10:25:54 +02:00

413 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<DialogWidget
ref="dlg"
name="falukant-messages"
:title="'falukant.messages.title'"
:isTitleTranslated="true"
icon="falukant/messages24.png"
:modal="false"
:buttons="[
{ text: 'falukant.messages.markAllRead', action: 'markAll' },
{ text: 'message.close', action: 'close' }
]"
width="520px"
height="420px"
>
<ul class="messages">
<li v-for="n in messages" :key="n.id" :class="{ unread: !n.shown }">
<div class="body">
<div v-if="formatBody(n).title" class="notification-title">{{ formatBody(n).title }}</div>
<div class="notification-description">{{ formatBody(n).description || formatBody(n) }}</div>
</div>
<div class="footer">
<span>{{ formatDate(n.createdAt) }}</span>
</div>
</li>
<li v-if="messages.length === 0" class="empty">{{ $t('falukant.messages.empty') }}</li>
</ul>
<div class="pagination" v-if="total > size">
<button @click="firstPage" :disabled="!canPrev">«</button>
<button @click="prevPage" :disabled="!canPrev"></button>
<span>
<input type="number" min="1" :max="totalPages" v-model.number="pageInput" @change="goToPage(pageInput)" />
/ {{ totalPages }}
</span>
<button @click="nextPage" :disabled="!canNext"></button>
<button @click="lastPage" :disabled="!canNext">»</button>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'FalukantMessagesDialog',
components: { DialogWidget },
data() {
return {
messages: [],
page: 1,
size: 10,
total: 0,
pageInput: 1,
};
},
methods: {
async open() {
this.page = 1;
this.pageInput = 1;
await this.load();
this.$refs.dlg.open();
// mark unread as shown
try { await apiClient.post('/api/falukant/notifications/mark-shown'); } catch {}
},
async markAll() {
try {
await apiClient.post('/api/falukant/notifications/mark-shown');
// reload to update shown flags and unread count
await this.load();
} catch (e) {
// ignore errors silently
}
},
async load() {
try {
const { data } = await apiClient.get('/api/falukant/notifications/all', { params: { page: this.page, size: this.size } });
if (data && Array.isArray(data.items)) {
this.messages = data.items;
this.total = data.total || 0;
} else {
this.messages = Array.isArray(data) ? data : [];
this.total = this.messages.length;
}
} catch (e) {
console.error('Failed to load messages', e);
this.messages = [];
this.total = 0;
}
},
nextPage() {
if (this.page < this.totalPages) {
this.page++;
this.pageInput = this.page;
this.load();
}
},
prevPage() {
if (this.page > 1) {
this.page--;
this.pageInput = this.page;
this.load();
}
},
firstPage() {
if (this.page !== 1) {
this.page = 1;
this.pageInput = 1;
this.load();
}
},
lastPage() {
if (this.page !== this.totalPages) {
this.page = this.totalPages;
this.pageInput = this.page;
this.load();
}
},
goToPage(val) {
let v = Number(val) || 1;
if (v < 1) v = 1;
if (v > this.totalPages) v = this.totalPages;
if (v !== this.page) {
this.page = v;
this.pageInput = v;
this.load();
} else {
this.pageInput = v;
}
},
formatDate(dt) {
try {
return new Date(dt).toLocaleString();
} catch { return dt; }
},
formatBody(n) {
// Wenn die Notification bereits title und description hat (z.B. von WebSocket Events)
if (n.title && n.description) {
// Parameter aus effects oder anderen Feldern extrahieren
const params = this.extractParams(n);
return {
title: this.interpolateString(n.title, params),
description: this.interpolateString(n.description, params)
};
}
let raw = n.tr || '';
let key = raw;
let params = {};
// 1) JSON-Format unterstützen: {"tr":"random_event.windfall","amount":1000,"characterName":"Max"}
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && parsed.tr) {
raw = parsed.tr;
key = parsed.tr;
// Alle anderen Felder als Parameter verwenden und formatieren
params = this.formatParams({ ...parsed });
delete params.tr;
// Merge in params extracted from nested structures (effects, character ids)
try {
const extracted = this.extractParams({ ...n, ...parsed, characterName: parsed.characterName || parsed.character_name || n.characterName || n.character_name });
for (const [k, v] of Object.entries(extracted || {})) {
if (!params.hasOwnProperty(k) || params[k] === undefined || params[k] === null || params[k] === '') {
params[k] = v;
}
}
} catch (e) {
// ignore extraction errors
}
}
} catch (e) {
// bei Parse-Fehler einfach weiter unten mit dem Rohwert arbeiten
}
}
}
// 2) Schlüssel normalisieren:
// - wenn bereits ein voller i18n-Key wie "falukant.notifications.production.overproduction",
// dann direkt verwenden
// - sonst in den Namespace "falukant.notifications." hängen
if (typeof key === 'string') {
const trimmedKey = key.trim();
if (trimmedKey.startsWith('falukant.')) {
key = trimmedKey;
} else {
key = 'falukant.notifications.' + trimmedKey;
}
}
// 3) Prüfe, ob es sich um ein random_event handelt mit title/description Struktur
if (key.startsWith('falukant.notifications.random_event.')) {
const eventId = key.replace('falukant.notifications.random_event.', '');
const eventKey = `falukant.notifications.random_event.${eventId}`;
try {
const titleKey = `${eventKey}.title`;
const descKey = `${eventKey}.description`;
// If no params were parsed from JSON, try to extract them from the notification (effects, character_id, etc.)
if ((!params || Object.keys(params).length === 0) && n) {
params = this.extractParams(n) || {};
}
if (this.$te(titleKey) && this.$te(descKey)) {
const title = this.$t(titleKey, params);
const description = this.$t(descKey, params);
return { title, description };
}
} catch (e) {
// Fallback zur alten Methode
}
}
// Fallback: Alte Methode für andere Notification-Typen
return this.$t(key, params);
},
formatParams(params) {
const formatted = {};
const locale = this.$i18n?.locale || 'de';
const isGerman = String(locale).startsWith('de');
// Geldbeträge formatieren
if (params.amount !== undefined) {
formatted.amount = this.formatMoney(params.amount);
}
if (params.absolute !== undefined) {
formatted.amount = this.formatMoney(params.absolute);
}
if (params.percent !== undefined) {
formatted.amount = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
}
// Gesundheit formatieren
if (params.change !== undefined) {
formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`;
}
if (params.healthChange !== undefined) {
formatted.healthChange = params.healthChange > 0 ? `+${params.healthChange}` : `${params.healthChange}`;
}
// Schaden formatieren
if (params.inventory_damage_percent !== undefined) {
formatted.damagePercent = ` Lagerbestand beschädigt: ${params.inventory_damage_percent.toFixed(1)}%.`;
}
if (params.storage_destruction_percent !== undefined) {
formatted.destructionPercent = ` Lager zerstört: ${params.storage_destruction_percent.toFixed(1)}%.`;
}
if (params.regionLabel) {
formatted.regionLabel = isGerman
? ` Ort: ${params.regionLabel}.`
: ` Location: ${params.regionLabel}.`;
}
if (params.spouses) {
formatted.spouses = isGerman
? ` Ehepartner: ${params.spouses}.`
: ` Spouses: ${params.spouses}.`;
}
if (params.children) {
formatted.children = isGerman
? ` Kinder: ${params.children}.`
: ` Children: ${params.children}.`;
}
if (params.lovers) {
formatted.lovers = isGerman
? ` Geliebte: ${params.lovers}.`
: ` Lovers: ${params.lovers}.`;
}
// Alle anderen Parameter übernehmen
for (const [key, value] of Object.entries(params)) {
if (!formatted.hasOwnProperty(key) && key !== 'tr') {
formatted[key] = value;
}
}
return formatted;
},
extractParams(n) {
const params = {};
// Parameter aus effects extrahieren
if (n.effects && Array.isArray(n.effects)) {
for (const effect of n.effects) {
if (effect.type === 'money_change') {
if (effect.absolute !== undefined) {
params.amount = effect.absolute;
} else if (effect.percent !== undefined) {
params.percent = effect.percent;
}
} else if (effect.type === 'character_health_change') {
if (effect.character_id) {
// Prefer explicit characterName from notification, otherwise fall back to provided name or use id placeholder
params.character_id = effect.character_id;
params.characterName = params.characterName || n.characterName || `#${effect.character_id}`;
}
if (effect.change !== undefined) {
params.change = effect.change;
}
} else if (effect.type === 'character_death') {
if (effect.character_id) {
params.character_id = effect.character_id;
params.characterName = params.characterName || n.characterName || `#${effect.character_id}`;
}
} else if (effect.type === 'storage_damage') {
if (effect.inventory_damage_percent !== undefined) {
params.inventory_damage_percent = effect.inventory_damage_percent;
}
if (effect.storage_destruction_percent !== undefined) {
params.storage_destruction_percent = effect.storage_destruction_percent;
}
}
}
}
// Weitere Parameter aus der Notification selbst
if (n.region_id && n.regionName) {
params.regionName = n.regionName;
}
if (n.character_id && n.characterName) {
params.characterName = n.characterName;
}
if (n.amount !== undefined) {
params.amount = n.amount;
}
if (n.deceased && typeof n.deceased === 'object') {
if (n.deceased.display_name) {
params.characterName = n.deceased.display_name;
}
if (n.deceased.region_label) {
params.regionLabel = n.deceased.region_label;
}
if (n.deceased.age_years !== undefined && n.deceased.age_years !== null) {
params.ageYears = n.deceased.age_years;
}
}
if (n.linked && typeof n.linked === 'object') {
if (Array.isArray(n.linked.spouses) && n.linked.spouses.length > 0) {
params.spouses = n.linked.spouses.join(', ');
}
if (Array.isArray(n.linked.children) && n.linked.children.length > 0) {
params.children = n.linked.children.join(', ');
}
if (Array.isArray(n.linked.lovers) && n.linked.lovers.length > 0) {
params.lovers = n.linked.lovers.join(', ');
}
}
return this.formatParams(params);
},
interpolateString(str, params) {
if (!str || typeof str !== 'string') return str;
let result = str;
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
} else {
// Entferne Platzhalter, wenn kein Wert vorhanden ist
result = result.replace(new RegExp(`\\s*\\{${key}\\}\\s*`, 'g'), '');
}
}
// Entferne doppelte Leerzeichen und trimme
result = result.replace(/\s+/g, ' ').trim();
return result;
},
formatMoney(amount) {
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
},
computed: {
totalPages() {
return Math.max(1, Math.ceil(this.total / this.size));
},
canPrev() { return this.page > 1; },
canNext() { return this.page < this.totalPages; }
}
};
</script>
<style scoped>
.messages {
list-style: none;
padding: 0;
margin: 0;
}
.messages > li { border: 1px solid #7BBE55; margin-bottom: .25em; padding: .5em; }
.messages > li.unread { font-weight: bold; }
.messages > li .body { display: flex; flex-direction: column; gap: 0.25em; }
.messages > li .notification-title { font-weight: bold; font-size: 1.05em; }
.messages > li .notification-description { color: #555; }
.messages > li .footer { color: #f9a22c; font-size: .8em; margin-top: .3em; display: flex; }
.messages > li .footer span:first-child { flex: 1; }
.empty { text-align: center; color: #777; padding: 1em; }
.pagination {
display: flex;
justify-content: center;
gap: .5em;
margin-top: .5em;
}
.pagination button { padding: .25em .6em; }
.pagination input[type="number"] { width: 4em; text-align: right; }
</style>