Files
yourpart3/frontend/src/views/falukant/PoliticsView.vue
Torsten Schulz (local) 3e74ae47f4 feat(i18n): add French language support and enhance localization
- Introduced French as a supported language across the application, updating locale files and adding translations for various components.
- Enhanced language handling logic to accommodate French, ensuring proper detection and fallback mechanisms.
- Updated UI elements to include French language options, improving accessibility for French-speaking users.
- Refactored SEO handling to include French in hreflang links, enhancing search engine indexing for multilingual content.
- Added new scripts for managing French translations and ensuring consistency across language files.
2026-04-07 18:04:03 +02:00

865 lines
34 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>
<div class="politics-view">
<StatusBar />
<SimpleTabs v-model="activeTab" :tabs="tabs" @change="onTabChange" />
<!-- TabInhalt -->
<div class="tab-content">
<!-- Aktuelle Positionen -->
<div v-if="activeTab === 'current'" class="tab-pane">
<div v-if="loading.current" class="loading">{{ $t('loading') }}</div>
<div v-else-if="currentPositions.length" class="politics-card-list">
<article v-for="pos in currentPositions" :key="pos.id" class="politics-card" :class="{ 'own-position': isOwnPosition(pos) }">
<div class="politics-card__header">
<strong>{{ $t(`falukant.politics.offices.${pos.officeType.name}`) }}</strong>
<span>{{ pos.region.name }}</span>
</div>
<div class="politics-card__meta">
<div class="politics-card__meta-row">
<span class="politics-card__meta-label">{{ $t('falukant.politics.current.holder') }}:</span>
<span class="politics-card__meta-value">
<template v-if="pos.character">
{{ pos.character.definedFirstName.name }} {{ pos.character.definedLastName.name }}
</template>
<template v-else></template>
</span>
</div>
<div class="politics-card__meta-row politics-card__meta-row--benefits">
<span class="politics-card__meta-label">{{ $t('falukant.politics.current.benefit') }}:</span>
<ul
v-if="politicsBenefitItems(pos).length"
class="politics-benefit-list"
>
<li
v-for="(b, i) in politicsBenefitItems(pos)"
:key="i"
>
{{ formatPoliticsBenefitItem(b) }}
</li>
</ul>
<span v-else class="politics-card__meta-value"></span>
</div>
<div class="politics-card__meta-row">
<span class="politics-card__meta-label">{{ $t('falukant.politics.current.termEnds') }}:</span>
<span class="politics-card__meta-value">
<template v-if="pos.termEnds">{{ formatDate(pos.termEnds) }}</template>
<template v-else></template>
</span>
</div>
</div>
</article>
</div>
<p v-else class="loading">{{ $t('falukant.politics.current.none') }}</p>
</div>
<div v-else-if="activeTab === 'powers'" class="tab-pane powers-tab">
<div v-if="loading.powers" class="loading">{{ $t('loading') }}</div>
<template v-else-if="myPowers">
<p v-if="!hasAnyPowers" class="politics-powers-empty">{{ $t('falukant.politics.powers.none') }}</p>
<template v-else>
<section v-if="myPowers.freeLoverSlots > 0" class="powers-section">
<h3>{{ $t('falukant.politics.powers.freeLoversTitle') }}</h3>
<p>{{ $t('falukant.politics.powers.freeLoversHint', { count: myPowers.freeLoverSlots }) }}</p>
</section>
<section v-if="myPowers.reputationPeriodic && myPowers.reputationPeriodic.length" class="powers-section">
<h3>{{ $t('falukant.politics.powers.reputationTitle') }}</h3>
<ul class="powers-list">
<li v-for="(r, i) in myPowers.reputationPeriodic" :key="i">
{{ $t('falukant.politics.powers.reputationLine', {
office: $t(`falukant.politics.offices.${r.officeTypeName}`),
days: r.daysUntilNext,
gain: r.gain
}) }}
</li>
</ul>
</section>
<section v-if="taxJurisdiction && taxJurisdiction.regions && taxJurisdiction.regions.length" class="powers-section">
<h3>{{ $t('falukant.politics.powers.taxTitle') }}</h3>
<p class="powers-hint">{{ $t('falukant.politics.powers.taxRange', { min: taxJurisdiction.taxMin, max: taxJurisdiction.taxMax }) }}</p>
<div v-for="reg in taxJurisdiction.regions" :key="reg.id" class="powers-tax-row">
<div>
<strong>{{ reg.name }}</strong>
<span class="powers-muted">({{ formatPoliticsRegionLevel(reg.regionType) }})</span>
</div>
<div class="powers-tax-edit">
<input
type="number"
:min="taxJurisdiction.taxMin"
:max="taxJurisdiction.taxMax"
step="0.1"
v-model.number="taxDraft[reg.id]"
/>
<button type="button" @click="saveRegionTax(reg.id)">{{ $t('falukant.politics.powers.taxSave') }}</button>
</div>
</div>
</section>
<section v-if="appointable.length" class="powers-section">
<h3>{{ $t('falukant.politics.powers.appointTitle') }}</h3>
<label class="powers-label">{{ $t('falukant.politics.powers.appointSlot') }}</label>
<select v-model="selectedAppointKey" class="powers-select">
<option value="">{{ $t('falukant.politics.powers.appointPick') }}</option>
<option v-for="a in appointable" :key="appointOptionKey(a)" :value="appointOptionKey(a)">
{{ $t(`falukant.politics.offices.${a.officeTypeName}`) }} {{ a.regionName }} ({{ a.seatsFree }})
</option>
</select>
<label class="powers-label">{{ $t('falukant.politics.powers.appointSearch') }}</label>
<input v-model="appointSearch" type="text" class="powers-input" @input="debouncedAppointSearch" />
<ul v-if="appointSearchResults.length" class="powers-search-results">
<li v-for="u in appointSearchResults" :key="u.characterId">
<button type="button" class="powers-linkish" @click="selectAppointTarget(u)">
{{ u.username }} {{ u.firstname }} {{ u.lastname }} ({{ u.town }})
</button>
</li>
</ul>
<p v-if="selectedAppointTarget" class="powers-selected-target">
{{ $t('falukant.politics.powers.appointSelected', { name: selectedAppointTarget.username }) }}
</p>
<button
type="button"
class="powers-submit"
:disabled="!canSubmitAppointment"
@click="submitAppointment"
>
{{ $t('falukant.politics.powers.appointSubmit') }}
</button>
</section>
</template>
</template>
</div>
<!-- OPEN Tab: hier zeigen wir 'openPolitics' -->
<div v-else-if="activeTab === 'openPolitics'" class="tab-pane">
<p class="politics-age-requirement">{{ $t('falukant.politics.open.ageRequirement') }}</p>
<div v-if="loading.openPolitics" class="loading">{{ $t('loading') }}</div>
<div v-else-if="openPolitics.length" class="politics-card-list">
<article v-for="e in openPolitics" :key="e.id" class="politics-card">
<div class="politics-card__header">
<strong>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</strong>
<span>{{ e.region.name }}</span>
</div>
<div class="politics-card__meta">
<span>{{ $t('falukant.politics.open.date') }}: {{ formatDate(e.date) }}</span>
</div>
<label class="politics-card__checkbox" :title="e.canApplyByAge === false ? $t('falukant.politics.open.minAgeHint') : null">
<input
type="checkbox"
:id="`apply-${e.id}`"
v-model="selectedApplications"
:value="e.id"
:disabled="e.alreadyApplied || e.canApplyByAge === false"
/>
<span>{{ $t('falukant.politics.bookmarkCandidate') }}</span>
</label>
</article>
</div>
<p v-else class="loading">{{ $t('falukant.politics.open.none') }}</p>
<div class="apply-button">
<button :disabled="!selectedApplications.length" @click="submitApplications">
{{ $t('falukant.politics.open.apply') }}
</button>
</div>
</div>
<!-- Wahlen -->
<div v-else-if="activeTab === 'elections'" class="tab-pane">
<div v-if="loading.elections" class="loading">{{ $t('loading') }}</div>
<div v-else-if="elections.length" class="politics-card-list">
<article v-for="e in elections" :key="e.id" class="politics-card politics-card--election">
<div class="politics-card__header">
<strong>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</strong>
<span>{{ e.region.name }}</span>
</div>
<div class="politics-card__meta">
<span>{{ $t('falukant.politics.elections.date') }}: {{ formatDate(e.date) }}</span>
<span>{{ $t('falukant.politics.elections.posts') }}: {{ e.postsToFill }}</span>
</div>
<div v-if="!e.voted" class="politics-card__vote">
<Multiselect v-model="selectedCandidates[e.id]" :options="e.candidates" multiple
:max="e.postsToFill" :close-on-select="false" :clear-on-select="false"
track-by="id" label="name" :custom-label="candidateLabel" placeholder="">
<template #option="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }} ({{ option.age }})
</template>
<template #selected="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }}
</template>
</Multiselect>
<button
:disabled="!selectedCandidates[e.id] || !selectedCandidates[e.id].length"
@click="submitVote(e.id)">
{{ $t('falukant.politics.elections.vote') }}
</button>
</div>
<ul v-else class="voted-list">
<li v-for="cid in e.votedFor" :key="cid">
<span v-if="findCandidateById(e, cid)">
{{ formatCandidateTitle(findCandidateById(e, cid)) }}
{{ findCandidateById(e, cid).name }}
</span>
</li>
<li v-if="!e.votedFor || !e.votedFor.length"></li>
</ul>
</article>
</div>
<p v-else class="loading">{{ $t('falukant.politics.elections.none') }}</p>
<div class="all-vote-button" v-if="hasAnyUnvoted">
<button :disabled="!hasAnySelection" @click="submitAllVotes">
{{ $t('falukant.politics.elections.voteAll') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import SimpleTabs from '@/components/SimpleTabs.vue';
import Multiselect from 'vue-multiselect';
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
const debugLog = () => {};
export default {
name: 'PoliticsView',
components: { StatusBar, SimpleTabs, Multiselect },
data() {
return {
activeTab: 'current',
tabs: [
{ value: 'current', label: 'falukant.politics.tabs.current' },
{ value: 'powers', label: 'falukant.politics.tabs.powers' },
{ value: 'openPolitics', label: 'falukant.politics.tabs.upcoming' },
{ value: 'elections', label: 'falukant.politics.tabs.elections' }
],
currentPositions: [],
openPolitics: [],
elections: [],
selectedCandidates: {},
selectedApplications: [],
ownCharacterId: null,
myPowers: null,
taxJurisdiction: null,
taxDraft: {},
appointable: [],
selectedAppointKey: '',
appointSearch: '',
appointSearchResults: [],
selectedAppointTarget: null,
_appointSearchTimer: null,
loading: {
current: false,
openPolitics: false,
elections: false,
powers: false
}
};
},
computed: {
hasAnyPowers() {
const p = this.myPowers;
if (!p) return false;
return (
(p.freeLoverSlots > 0) ||
(p.reputationPeriodic && p.reputationPeriodic.length) ||
(this.taxJurisdiction?.regions?.length > 0) ||
(this.appointable.length > 0)
);
},
canSubmitAppointment() {
return Boolean(this.selectedAppointKey && this.selectedAppointTarget?.characterId);
},
hasAnySelection() {
return Object.values(this.selectedCandidates)
.some(arr => Array.isArray(arr) && arr.length > 0);
},
hasAnyUnvoted() {
return this.elections.some(e => !e.voted);
}
},
mounted() {
this.loadOwnCharacterId();
this.loadCurrentPositions();
},
methods: {
politicsBenefitItems(pos) {
const raw = pos?.benefit;
if (!Array.isArray(raw) || !raw.length) {
return [];
}
if (raw.every((x) => typeof x === 'string') && raw.includes('*')) {
return [this.$t('falukant.politics.current.benefit_all')];
}
return raw;
},
formatPoliticsRegionLevel(key) {
const k = String(key || '');
const path = `falukant.politics.regionLevels.${k}`;
const t = this.$t(path);
return t !== path ? t : k;
},
formatPoliticsAppointOfficeLabels(officeTrs) {
if (!Array.isArray(officeTrs) || !officeTrs.length) {
return '';
}
return officeTrs
.map((tr) => {
const path = `falukant.politics.offices.${tr}`;
const t = this.$t(path);
return t !== path ? t : String(tr);
})
.join(', ');
},
formatPoliticsBenefitItem(b) {
if (b == null) {
return '';
}
if (typeof b === 'string') {
return b;
}
if (typeof b === 'object' && b.tr) {
if (b.tr === 'tax_exemption' && b.params?.all) {
return this.$t('falukant.politics.benefits.tax_exemption_all');
}
if (b.tr === 'tax_exemption' && Array.isArray(b.params?.regions)) {
const labels = b.params.regions.map((r) => this.formatPoliticsRegionLevel(r)).join(', ');
return this.$t('falukant.politics.benefits.tax_exemption', { regions: labels });
}
if (b.tr === 'daily_salary') {
return this.$t('falukant.politics.benefits.daily_salary', {
amount: this.formatPoliticsMoney(b.params?.amount)
});
}
if (b.tr === 'reputation_periodic') {
return this.$t('falukant.politics.benefits.reputation_periodic', {
days: b.params?.intervalDays ?? '—',
gain: b.params?.gain ?? '—'
});
}
if (b.tr === 'appoint_politicians' && Array.isArray(b.params?.officeTrs)) {
const labels = this.formatPoliticsAppointOfficeLabels(b.params.officeTrs);
return this.$t('falukant.politics.benefits.appoint_politicians', { offices: labels });
}
if (b.tr === 'set_regional_tax') {
const sk = String(b.params?.scope || 'local');
const scopePath = `falukant.politics.benefits.tax_scope.${sk}`;
const st = this.$t(scopePath);
const scopeLabel = st !== scopePath ? st : sk;
return this.$t('falukant.politics.benefits.set_regional_tax', { scope: scopeLabel });
}
if (b.tr === 'free_lover_slots') {
return this.$t('falukant.politics.benefits.free_lover_slots', { count: b.params?.count ?? 1 });
}
if (b.tr === 'guard_protection') {
return this.$t('falukant.politics.benefits.guard_protection');
}
if (b.tr === 'court_immunity') {
return this.$t('falukant.politics.benefits.court_immunity');
}
if (b.tr === 'generic_benefit') {
return this.$t('falukant.politics.benefits.generic', { code: b.params?.code || '' });
}
}
return String(b);
},
onTabChange(tab) {
if (tab === 'current') {
this.loadCurrentPositions();
}
if (tab === 'powers') {
this.loadPowers();
}
if (tab === 'openPolitics') {
this.loadOpenPolitics();
}
if (tab === 'elections') {
this.loadElections();
}
},
async loadPowers() {
this.loading.powers = true;
try {
const [mp, tj, ap] = await Promise.all([
apiClient.get('/api/falukant/politics/my-powers'),
apiClient.get('/api/falukant/politics/tax-jurisdiction'),
apiClient.get('/api/falukant/politics/appointable-offices')
]);
this.myPowers = mp.data;
this.taxJurisdiction = tj.data;
this.appointable = Array.isArray(ap.data) ? ap.data : [];
const draft = {};
(this.taxJurisdiction?.regions || []).forEach((r) => {
draft[r.id] = r.taxPercent;
});
this.taxDraft = draft;
} catch (err) {
console.error('[PoliticsView] loadPowers', err);
showApiError(this, err, this.$t('falukant.politics.powers.loadError'));
} finally {
this.loading.powers = false;
}
},
appointOptionKey(a) {
return `${a.officeTypeId}:${a.regionId}`;
},
parseAppointKey(key) {
const [officeTypeId, regionId] = String(key).split(':').map((x) => parseInt(x, 10));
if (Number.isNaN(officeTypeId) || Number.isNaN(regionId)) return null;
return { officeTypeId, regionId };
},
async saveRegionTax(regionId) {
const percent = this.taxDraft[regionId];
try {
await apiClient.put(`/api/falukant/politics/region/${regionId}/tax`, { percent });
showSuccess(this, this.$t('falukant.politics.powers.taxSaved'));
await this.loadPowers();
} catch (err) {
showApiError(this, err, this.$t('falukant.politics.powers.taxError'));
}
},
debouncedAppointSearch() {
if (this._appointSearchTimer) clearTimeout(this._appointSearchTimer);
this._appointSearchTimer = setTimeout(() => this.runAppointSearch(), 350);
},
async runAppointSearch() {
const q = (this.appointSearch || '').trim();
if (q.length < 2) {
this.appointSearchResults = [];
return;
}
try {
const { data } = await apiClient.get('/api/falukant/users/search', { params: { q } });
this.appointSearchResults = Array.isArray(data) ? data : [];
} catch (err) {
this.appointSearchResults = [];
}
},
selectAppointTarget(u) {
this.selectedAppointTarget = u;
},
async submitAppointment() {
const parsed = this.parseAppointKey(this.selectedAppointKey);
if (!parsed || !this.selectedAppointTarget?.characterId) return;
try {
await apiClient.post('/api/falukant/politics/appointments', {
targetCharacterId: this.selectedAppointTarget.characterId,
officeTypeId: parsed.officeTypeId,
regionId: parsed.regionId
});
showSuccess(this, this.$t('falukant.politics.powers.appointSuccess'));
this.selectedAppointKey = '';
this.selectedAppointTarget = null;
this.appointSearch = '';
this.appointSearchResults = [];
await this.loadPowers();
await this.loadCurrentPositions();
} catch (err) {
showApiError(this, err, this.$t('falukant.politics.powers.appointError'));
}
},
async loadCurrentPositions() {
this.loading.current = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/overview');
debugLog('[PoliticsView] loadCurrentPositions - API response:', data);
debugLog('[PoliticsView] loadCurrentPositions - ownCharacterId at load time:', this.ownCharacterId);
this.currentPositions = data;
debugLog('[PoliticsView] loadCurrentPositions - Loaded', data.length, 'positions');
} catch (err) {
console.error('[PoliticsView] Error loading current positions', err);
} finally {
this.loading.current = false;
}
},
async loadOpenPolitics() {
this.loading.openPolitics = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/open');
this.openPolitics = Array.isArray(data) ? data : [];
// Bereits beworbene Positionen vorselektieren, damit die Checkbox
// sichtbar markiert bleibt.
this.selectedApplications = this.openPolitics
.filter(e => e.alreadyApplied)
.map(e => e.id);
} catch (err) {
console.error('Error loading open politics', err);
} finally {
this.loading.openPolitics = false;
}
},
async loadElections() {
this.loading.elections = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/elections');
this.elections = data;
data.forEach(e => {
this.selectedCandidates[e.id] = [];
});
} catch (err) {
console.error('Error loading elections', err);
} finally {
this.loading.elections = false;
}
},
candidateLabel(option) {
const title = this.$t(`falukant.titles.${option.gender}.${option.title}`);
return `${title} ${option.name} (${option.age})`;
},
findCandidateById(election, candidateId) {
return election.candidates.find(c => c.id === candidateId) || {};
},
formatCandidateTitle(candidate) {
if (!candidate) return '';
return this.$t(`falukant.titles.${candidate.gender}.${candidate.title}`);
},
async submitVote(electionId) {
const singlePayload = [
{
electionId: electionId,
candidateIds: this.selectedCandidates[electionId].map(c => c.id)
}
];
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: singlePayload }
);
await this.loadElections();
showSuccess(this, this.$t('falukant.politics.voteSuccess'));
} catch (err) {
console.error(`Error submitting vote for election ${electionId}`, err);
showApiError(this, err, this.$t('falukant.politics.voteError'));
}
},
async submitAllVotes() {
const payload = Object.entries(this.selectedCandidates)
.filter(([eid, arr]) => Array.isArray(arr) && arr.length > 0)
.map(([eid, arr]) => ({
electionId: parseInt(eid, 10),
candidateIds: arr.map(c => c.id)
}));
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: payload }
);
await this.loadElections();
showSuccess(this, this.$t('falukant.politics.voteAllSuccess'));
} catch (err) {
console.error('Error submitting all votes', err);
showApiError(this, err, this.$t('falukant.politics.voteAllError'));
}
},
formatDate(ts) {
return new Date(ts).toLocaleDateString(this.$i18n.locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
},
/** Geldbetrag wie in Familie/Bank (exakter Wert, kein „ca.“). */
formatPoliticsMoney(value) {
const n = Number(value);
if (!Number.isFinite(n)) {
return '—';
}
return new Intl.NumberFormat(this.$i18n.locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(n);
},
async loadOwnCharacterId() {
try {
const { data } = await apiClient.get('/api/falukant/info');
if (data.character && data.character.id) {
this.ownCharacterId = data.character.id;
}
} catch (err) {
console.error('[PoliticsView] Error loading own character ID', err);
}
},
isOwnPosition(pos) {
if (!this.ownCharacterId || !pos.character) {
return false;
}
return pos.character.id === this.ownCharacterId;
},
async submitApplications() {
try {
const response = await apiClient.post(
'/api/falukant/politics/open',
{ electionIds: this.selectedApplications }
);
// Speichere die IDs der erfolgreich beworbenen Positionen
const appliedIds = response.data?.applied || [];
// Lade die Daten neu
await this.loadOpenPolitics();
// Stelle sicher, dass alle bereits beworbenen Positionen (inkl. der gerade beworbenen) vorselektiert bleiben
this.selectedApplications = this.openPolitics
.filter(e => e.alreadyApplied || appliedIds.includes(e.id))
.map(e => e.id);
showSuccess(this, this.$t('falukant.politics.applyBookmarkSuccess'));
} catch (err) {
console.error('Error submitting applications', err);
if (err?.response?.data?.error === 'too_young') {
showApiError(this, err, this.$t('falukant.politics.too_young'));
return;
}
showApiError(this, err, this.$t('falukant.politics.applyError'));
}
}
}
};
</script>
<style scoped>
.politics-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.simple-tabs {
margin-bottom: 16px;
}
.tab-content {
display: flex;
flex-direction: column;
}
.tab-pane {
display: flex;
flex-direction: column;
}
.politics-age-requirement {
margin: 0 0 10px 0;
font-size: 0.95em;
color: var(--color-text-secondary);
}
.politics-card-list {
display: grid;
gap: 12px;
}
.politics-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.72);
}
.politics-card.own-position {
border-color: rgba(120, 195, 138, 0.45);
background: rgba(236, 248, 238, 0.92);
}
.politics-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.politics-card__meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
color: var(--color-text-secondary);
}
.politics-card__meta-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
}
.politics-card__meta-label {
font-weight: 600;
color: var(--color-text-muted, var(--color-text-secondary));
}
.politics-card__meta-value {
line-height: 1.45;
}
.politics-benefit-list {
margin: 0;
padding-left: 1.25rem;
line-height: 1.5;
list-style: disc;
}
.politics-benefit-list li {
margin: 0.2em 0;
padding-inline-start: 0.15em;
}
.politics-card__meta-row--benefits .politics-benefit-list {
width: 100%;
max-width: 100%;
}
.politics-card__checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
width: fit-content;
}
.politics-card__vote {
display: grid;
gap: 12px;
}
.loading {
text-align: center;
font-style: italic;
margin: 20px 0;
}
.voted-list {
list-style: none;
margin: 0;
padding: 0;
}
.all-vote-button {
padding: 10px 0;
text-align: right;
}
.all-vote-button button {
padding: 6px 12px;
cursor: pointer;
}
@media (max-width: 900px) {
.politics-card__header {
flex-direction: column;
align-items: flex-start;
}
}
.powers-tab .powers-section {
margin-bottom: 1.5rem;
padding: 1rem 1.1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.75);
}
.powers-tab h3 {
margin: 0 0 0.5rem 0;
font-size: 1.05rem;
}
.powers-hint,
.politics-powers-empty {
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.powers-list {
margin: 0;
padding-left: 1.2rem;
}
.powers-tax-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.powers-tax-edit {
display: flex;
align-items: center;
gap: 8px;
}
.powers-tax-edit input {
width: 5.5rem;
padding: 4px 8px;
}
.powers-muted {
margin-left: 6px;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.powers-label {
display: block;
margin: 10px 0 4px;
font-size: 0.92rem;
}
.powers-input,
.powers-select {
width: 100%;
max-width: 28rem;
padding: 6px 10px;
}
.powers-search-results {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
.powers-linkish {
background: none;
border: none;
padding: 4px 0;
cursor: pointer;
text-align: left;
color: var(--color-primary, #6b4a2a);
text-decoration: underline;
}
.powers-selected-target {
margin: 10px 0 0;
font-size: 0.95rem;
}
.powers-submit {
margin-top: 12px;
padding: 6px 14px;
cursor: pointer;
}
</style>