All checks were successful
Deploy to production / deploy (push) Successful in 2m3s
- Added mappings for character titles and genders in the FalukantService to enrich notifications with additional character information. - Updated the MessagesDialog component to utilize the new title and gender data, improving the display of director names based on their titles and genders. - Ensured that character names are displayed correctly, enhancing the overall user experience in notifications.
592 lines
22 KiB
Vue
592 lines
22 KiB
Vue
<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; }
|
||
},
|
||
/** Vereinigt Top-Level-Felder mit in `tr` eingebettetem JSON (Daemon liefert effects oft nur dort). */
|
||
mergeNotificationPayload(n) {
|
||
if (!n || typeof n !== 'object') return n || {};
|
||
let merged = { ...n };
|
||
const raw = n.tr;
|
||
if (raw && typeof raw === 'object') {
|
||
merged = { ...merged, ...raw };
|
||
}
|
||
if (typeof raw === 'string') {
|
||
const trimmed = raw.trim();
|
||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
if (parsed && typeof parsed === 'object') {
|
||
merged = { ...merged, ...parsed };
|
||
}
|
||
} catch {
|
||
/* Rohstring bleibt in merged.tr */
|
||
}
|
||
}
|
||
}
|
||
if (merged.character_name && merged.characterName == null) {
|
||
merged.characterName = merged.character_name;
|
||
}
|
||
return merged;
|
||
},
|
||
|
||
formatBody(n) {
|
||
const payload = this.mergeNotificationPayload(n);
|
||
// Wenn die Notification bereits title und description hat (z.B. von WebSocket Events)
|
||
if (payload.title && payload.description) {
|
||
// Parameter aus effects oder anderen Feldern extrahieren
|
||
const params = this.extractParams(payload);
|
||
return {
|
||
title: this.interpolateString(payload.title, params),
|
||
description: this.interpolateString(payload.description, params)
|
||
};
|
||
}
|
||
|
||
let raw = payload.tr || '';
|
||
let key = raw;
|
||
let params = {};
|
||
|
||
// Fallback: Manche Daemon-Events liefern event_id/event_type, aber kein tr.
|
||
if ((!raw || !String(raw).trim()) && payload?.event_id) {
|
||
raw = payload.event_type === 'random_event'
|
||
? `random_event.${payload.event_id}`
|
||
: String(payload.event_id);
|
||
key = raw;
|
||
}
|
||
|
||
// 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({ ...payload, ...parsed, characterName: parsed.characterName || parsed.character_name || payload.characterName || payload.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`;
|
||
const extracted = this.extractParams(payload) || {};
|
||
params = { ...extracted, ...params };
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// Immer Parameter aus dem vollständigen Payload (nach merge), z. B. director.resignation_risk_high
|
||
const extractedAll = this.extractParams(payload) || {};
|
||
params = { ...extractedAll, ...params };
|
||
|
||
// 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 base = this.mergeNotificationPayload(n);
|
||
const params = {};
|
||
const locale = this.$i18n?.locale || 'de';
|
||
const isGerman = String(locale).startsWith('de');
|
||
|
||
if (base.characterName) {
|
||
params.characterName = base.characterName;
|
||
} else if (base.character_name) {
|
||
params.characterName = base.character_name;
|
||
}
|
||
|
||
// Parameter aus effects extrahieren (Daemon: effects oft nur im JSON in tr)
|
||
if (base.effects && Array.isArray(base.effects)) {
|
||
for (const effect of base.effects) {
|
||
if (effect.type === 'money_change') {
|
||
if (effect.absolute !== undefined && effect.absolute !== null) {
|
||
const absNum = Number(effect.absolute);
|
||
if (Number.isFinite(absNum)) {
|
||
// Gebuchter Betrag ist bei Verlust negativ; Anzeige „Verlust: …“ erwartet positive Zahl
|
||
params.amount = Math.abs(absNum);
|
||
}
|
||
} else if (effect.percent !== undefined) {
|
||
params.percent = effect.percent;
|
||
}
|
||
} else if (effect.type === 'price_change') {
|
||
if (effect.percent !== undefined && effect.percent !== null) {
|
||
const p = Number(effect.percent);
|
||
if (Number.isFinite(p)) {
|
||
params.priceChangePercent = `${p > 0 ? '+' : ''}${p.toFixed(1)}`;
|
||
}
|
||
}
|
||
} else if (effect.type === 'production_quality_change') {
|
||
if (effect.change !== undefined && effect.change !== null) {
|
||
const v = Number(effect.change);
|
||
if (Number.isFinite(v)) {
|
||
params.productionQualityChange = (v > 0 ? '+' : '') + (Number.isInteger(v) ? String(v) : v.toFixed(1));
|
||
}
|
||
}
|
||
} else if (effect.type === 'weather_change') {
|
||
params.hasWeatherChange = true;
|
||
} 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 || base.characterName || base.character_name || `#${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 || base.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 (base.region_id != null) {
|
||
if (base.regionName) {
|
||
params.regionName = base.regionName;
|
||
} else if (base.region_name) {
|
||
params.regionName = base.region_name;
|
||
}
|
||
}
|
||
|
||
// Daemon: Meta-Objekte (auch wenn effects[] schon Werte liefert — fehlende Werte ergänzen)
|
||
if (params.priceChangePercent == null && base.price_change && typeof base.price_change === 'object') {
|
||
const applied = base.price_change.applied !== false;
|
||
const pct = base.price_change.percent;
|
||
if (applied && pct != null && Number.isFinite(Number(pct))) {
|
||
const p = Number(pct);
|
||
params.priceChangePercent = `${p > 0 ? '+' : ''}${p.toFixed(1)}`;
|
||
}
|
||
}
|
||
if (params.productionQualityChange == null && base.production_quality && typeof base.production_quality === 'object') {
|
||
const applied = base.production_quality.applied !== false;
|
||
const ch = base.production_quality.change;
|
||
if (applied && ch != null && Number.isFinite(Number(ch))) {
|
||
const v = Number(ch);
|
||
params.productionQualityChange = (v > 0 ? '+' : '') + (Number.isInteger(v) ? String(v) : v.toFixed(1));
|
||
}
|
||
}
|
||
|
||
params.priceEffectLine = this.buildRegionalFestivalEffectsLine(params, isGerman);
|
||
if (base.character_id && base.characterName) {
|
||
params.characterName = base.characterName;
|
||
}
|
||
if (base.amount !== undefined) {
|
||
params.amount = base.amount;
|
||
}
|
||
if (base.risk_percent !== undefined) {
|
||
params.risk_percent = base.risk_percent;
|
||
}
|
||
if (base.satisfaction !== undefined) {
|
||
params.satisfaction = base.satisfaction;
|
||
}
|
||
if (base.threshold_percent !== undefined) {
|
||
params.threshold_percent = base.threshold_percent;
|
||
}
|
||
const isDirectorResign = base.event === 'director_resignation_risk_high'
|
||
|| base.event === 'director_resigned'
|
||
|| (typeof base.tr === 'string' && (base.tr.includes('director.resignation') || base.tr.includes('director.resigned')));
|
||
if (isDirectorResign) {
|
||
const baseName = params.characterName
|
||
|| base.character_name
|
||
|| (base.director_character_id != null ? `#${base.director_character_id}` : '');
|
||
const titleTr = base.director_title_tr || base.character_title_tr || null;
|
||
const gender = base.director_gender || base.character_gender || null;
|
||
if (titleTr && gender) {
|
||
const k = `falukant.titles.${gender}.${titleTr}`;
|
||
const translatedTitle = this.$t(k);
|
||
params.directorName = translatedTitle !== k ? `${translatedTitle} ${baseName}`.trim() : `${titleTr} ${baseName}`.trim();
|
||
} else {
|
||
params.directorName = baseName;
|
||
}
|
||
}
|
||
if (base.director_id !== undefined) {
|
||
params.director_id = base.director_id;
|
||
}
|
||
if (base.director_character_id !== undefined) {
|
||
params.director_character_id = base.director_character_id;
|
||
}
|
||
if (base.affection !== undefined) {
|
||
params.affection = base.affection;
|
||
}
|
||
if (base.visibility !== undefined) {
|
||
params.visibility = base.visibility;
|
||
}
|
||
if (base.discretion !== undefined) {
|
||
params.discretion = base.discretion;
|
||
}
|
||
if (base.months_underfunded !== undefined) {
|
||
params.months_underfunded = base.months_underfunded;
|
||
}
|
||
if (base.relationship_id !== undefined) {
|
||
params.relationship_id = base.relationship_id;
|
||
}
|
||
|
||
if (base.deceased && typeof base.deceased === 'object') {
|
||
if (base.deceased.display_name) {
|
||
params.characterName = base.deceased.display_name;
|
||
}
|
||
if (base.deceased.region_label) {
|
||
params.regionLabel = base.deceased.region_label;
|
||
}
|
||
if (base.deceased.age_years !== undefined && base.deceased.age_years !== null) {
|
||
params.ageYears = base.deceased.age_years;
|
||
}
|
||
}
|
||
|
||
if (base.linked && typeof base.linked === 'object') {
|
||
if (Array.isArray(base.linked.spouses) && base.linked.spouses.length > 0) {
|
||
params.spouses = base.linked.spouses.join(', ');
|
||
}
|
||
if (Array.isArray(base.linked.children) && base.linked.children.length > 0) {
|
||
params.children = base.linked.children.join(', ');
|
||
}
|
||
if (Array.isArray(base.linked.lovers) && base.linked.lovers.length > 0) {
|
||
params.lovers = base.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);
|
||
},
|
||
|
||
/**
|
||
* Zeilen für regionale Events (Fest o. ä.): Wetter, Preis, Produktionsqualität — aus effects[] und/oder price_change / production_quality.
|
||
*/
|
||
buildRegionalFestivalEffectsLine(params, isGerman) {
|
||
const parts = [];
|
||
const kw = 'falukant.notifications.random_event.effects.festival_weather_line';
|
||
const kp = 'falukant.notifications.random_event.effects.festival_price_line';
|
||
const kq = 'falukant.notifications.random_event.effects.festival_quality_line';
|
||
if (params.hasWeatherChange) {
|
||
if (this.$te(kw)) parts.push(String(this.$t(kw)));
|
||
else parts.push(isGerman ? 'Wetter in der Region schlägt um.' : 'The regional weather shifts.');
|
||
}
|
||
if (params.priceChangePercent != null) {
|
||
if (this.$te(kp)) {
|
||
parts.push(String(this.$t(kp, { percent: params.priceChangePercent })));
|
||
} else {
|
||
const legacy = 'falukant.notifications.random_event.effects.price_effect_suffix';
|
||
parts.push(
|
||
this.$te(legacy)
|
||
? String(this.$t(legacy, { percent: params.priceChangePercent }))
|
||
: (isGerman
|
||
? `Warenpreise etwa ${params.priceChangePercent} %.`
|
||
: `Goods prices about ${params.priceChangePercent}%.`)
|
||
);
|
||
}
|
||
}
|
||
if (params.productionQualityChange != null) {
|
||
if (this.$te(kq)) {
|
||
parts.push(String(this.$t(kq, { change: params.productionQualityChange })));
|
||
} else {
|
||
parts.push(
|
||
isGerman
|
||
? `Produktionsqualität etwa ${params.productionQualityChange}.`
|
||
: `Production quality about ${params.productionQualityChange}.`
|
||
);
|
||
}
|
||
}
|
||
if (!parts.length) return '';
|
||
return ` ${parts.join(' ')}`;
|
||
},
|
||
},
|
||
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>
|