ADD - build localized CityWarn experience

Add the Pinia domain, citizen feed, map, archive, settings, alert detail, and authorized publishing workflows. Ship complete German and English locale trees with a browser-safe fallback and contract coverage for every CityWarn label.
This commit is contained in:
Leon.Schmidt
2026-08-18 15:36:17 +02:00
parent 1a2189ceb8
commit 742b2d2a0d
9 changed files with 2589 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCityWarnStore } from '@/stores/citywarn'
import type { CityWarnAlert, CityWarnBootstrap } from '@/types/citywarn'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
vi.mock('@/stores/phone', () => ({
usePhoneStore: () => ({ device: null, saveDeviceNamespace: vi.fn() }),
}))
const mockNuiCall = vi.mocked(nuiCall)
function alert(overrides: Partial<CityWarnAlert> = {}): CityWarnAlert {
return {
area: {
centerX: 100,
centerY: 200,
label: 'Mission Row',
radius: 500,
type: 'radius',
},
authorName: 'Alex Morgan',
body: 'Avoid the area.',
category: 'police',
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
id: 'alert-1',
instructions: 'Use another route.',
revision: 1,
severity: 'danger',
sourceLabel: 'LSPD',
startsAt: Date.now(),
status: 'active',
title: 'Police operation',
updatedAt: Date.now(),
updates: [],
...overrides,
}
}
const bootstrap: CityWarnBootstrap = {
active: [alert()],
archive: [],
context: {
allowedCategories: ['police'],
canCityWide: true,
canPublish: true,
gradeLabel: 'Sergeant',
jobLabel: 'LSPD',
maximumSeverity: 'extreme',
onDuty: true,
requiresDuty: true,
},
onlinePlayers: 42,
}
describe('CityWarn store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads server-authoritative alerts and publishing context', async () => {
mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true })
const citywarn = useCityWarnStore()
expect(await citywarn.load()).toBe(true)
expect(citywarn.active).toEqual(bootstrap.active)
expect(citywarn.context?.canPublish).toBe(true)
expect(citywarn.onlinePlayers).toBe(42)
expect(mockNuiCall).toHaveBeenCalledWith('citywarn:bootstrap')
})
it('filters warnings by category, severity and location preference', () => {
const citywarn = useCityWarnStore()
const policeRadius = alert()
const cityWide = alert({
area: {
centerX: null,
centerY: null,
label: 'Los Santos',
radius: null,
type: 'city',
},
category: 'public_safety',
severity: 'extreme',
})
citywarn.preferences.categories.police = false
expect(citywarn.accepts(policeRadius)).toBe(false)
citywarn.preferences.categories.police = true
citywarn.preferences.minimumSeverity = 'extreme'
expect(citywarn.accepts(policeRadius)).toBe(false)
citywarn.preferences.locationAlerts = false
expect(citywarn.accepts(cityWide)).toBe(true)
})
})
+195
View File
@@ -0,0 +1,195 @@
import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
import type {
CityWarnAlert,
CityWarnBootstrap,
CityWarnCategory,
CityWarnEventData,
CityWarnPreferences,
CityWarnPublishInput,
CityWarnSeverity,
} from '@/types/citywarn'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const CITYWARN_CATEGORIES: CityWarnCategory[] = [
'public_safety',
'police',
'fire',
'medical',
'infrastructure',
'evacuation',
]
export const CITYWARN_SEVERITIES: CityWarnSeverity[] = [
'information',
'warning',
'danger',
'extreme',
]
const severityRank: Record<CityWarnSeverity, number> = {
danger: 2,
extreme: 3,
information: 0,
warning: 1,
}
export const DEFAULT_CITYWARN_PREFERENCES: CityWarnPreferences = {
categories: Object.fromEntries(
CITYWARN_CATEGORIES.map((category) => [category, true]),
) as Record<CityWarnCategory, boolean>,
locationAlerts: true,
minimumSeverity: 'information',
}
function parsePreferences(value: unknown): CityWarnPreferences {
const candidate =
value && typeof value === 'object'
? (value as Partial<CityWarnPreferences>)
: {}
const minimumSeverity = CITYWARN_SEVERITIES.includes(
candidate.minimumSeverity as CityWarnSeverity,
)
? (candidate.minimumSeverity as CityWarnSeverity)
: DEFAULT_CITYWARN_PREFERENCES.minimumSeverity
return {
categories: Object.fromEntries(
CITYWARN_CATEGORIES.map((category) => [
category,
candidate.categories?.[category] !== false,
]),
) as Record<CityWarnCategory, boolean>,
locationAlerts: candidate.locationAlerts !== false,
minimumSeverity,
}
}
function acceptsAlert(
preferences: CityWarnPreferences,
alert: CityWarnAlert,
): boolean {
if (!preferences.categories[alert.category]) return false
if (
severityRank[alert.severity] < severityRank[preferences.minimumSeverity]
) {
return false
}
return preferences.locationAlerts || alert.area.type === 'city'
}
export const useCityWarnStore = defineStore('citywarn', {
state: () => ({
active: [] as CityWarnAlert[],
archive: [] as CityWarnAlert[],
context: null as CityWarnBootstrap['context'] | null,
error: '',
initialized: false,
isLoading: false,
onlinePlayers: 0,
preferences: parsePreferences(null),
}),
getters: {
visibleActive(state): CityWarnAlert[] {
return state.active.filter((alert) =>
acceptsAlert(state.preferences, alert),
)
},
},
actions: {
hydratePreferences(): void {
const phone = usePhoneStore()
this.preferences = parsePreferences(
phone.device?.data.citywarn?.payload ?? null,
)
},
savePreferences(): void {
usePhoneStore().saveDeviceNamespace('citywarn', this.preferences)
},
setCategory(category: CityWarnCategory, enabled: boolean): void {
this.preferences.categories[category] = enabled
this.savePreferences()
},
setLocationAlerts(enabled: boolean): void {
this.preferences.locationAlerts = enabled
this.savePreferences()
},
setMinimumSeverity(severity: CityWarnSeverity): void {
this.preferences.minimumSeverity = severity
this.savePreferences()
},
accepts(alert: CityWarnAlert): boolean {
return acceptsAlert(this.preferences, alert)
},
async load(): Promise<boolean> {
this.isLoading = true
this.hydratePreferences()
const response = await nuiCall<CityWarnBootstrap>('citywarn:bootstrap')
this.isLoading = false
if (response.success && response.data) {
this.active = response.data.active
this.archive = response.data.archive
this.context = response.data.context
this.onlinePlayers = response.data.onlinePlayers
this.error = ''
this.initialized = true
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async refresh(): Promise<boolean> {
return this.load()
},
applyEvent(data: CityWarnEventData): void {
const alert = data.alert
if (!alert) return
this.active = this.active.filter((item) => item.id !== alert.id)
this.archive = this.archive.filter((item) => item.id !== alert.id)
if (alert.status === 'active') this.active.unshift(alert)
else this.archive.unshift(alert)
},
async publish(
input: CityWarnPublishInput,
): Promise<NuiResponse<{ alert: CityWarnAlert; recipients: number }>> {
const response = await nuiCall<{
alert: CityWarnAlert
recipients: number
}>('citywarn:publish', input)
if (response.success && response.data) {
this.applyEvent({ alert: response.data.alert })
this.error = ''
} else this.error = response.error ?? 'request_failed'
return response
},
async addUpdate(
alert: CityWarnAlert,
message: string,
): Promise<NuiResponse<{ alert: CityWarnAlert }>> {
const response = await nuiCall<{ alert: CityWarnAlert }>(
'citywarn:update',
{ id: alert.id, message, revision: alert.revision },
)
if (response.success && response.data) {
this.applyEvent({ alert: response.data.alert })
this.error = ''
} else this.error = response.error ?? 'request_failed'
return response
},
async resolve(
alert: CityWarnAlert,
message: string,
): Promise<NuiResponse<{ alert: CityWarnAlert }>> {
const response = await nuiCall<{ alert: CityWarnAlert }>(
'citywarn:resolve',
{ id: alert.id, message, revision: alert.revision },
)
if (response.success && response.data) {
this.applyEvent({ alert: response.data.alert })
this.error = ''
} else this.error = response.error ?? 'request_failed'
return response
},
},
})
+127
View File
@@ -82,6 +82,133 @@ describe('phone locale fallback', () => {
expect(phone.t('Apps.crypto.profile.shareKey')).toBe('Share')
})
it('keeps CityWarn copy translated with a partial server locale', () => {
const phone = usePhoneStore()
phone.open({ locales: { Apps: { citywarn: { name: 'CityWarn' } } } })
const cityWarnKeys = [
'name',
'navigation',
'loading',
'issuedBy',
'updated',
'expires',
'affectedArea',
'currentLocation',
'details',
'instructions',
'timeline',
'publishedBy',
'radius',
'emptyArchive',
'emptyArchiveBody',
'emptyFiltered',
'emptyFilteredBody',
'mapTitle',
'mapBody',
...['active', 'map', 'archive', 'settings'].map((key) => `tabs.${key}`),
...['safe', 'safeBody', 'active', 'activeBody'].map(
(key) => `hero.${key}`,
),
...['active', 'resolved', 'expired'].map((key) => `status.${key}`),
...['information', 'warning', 'danger', 'extreme'].map(
(key) => `severity.${key}`,
),
...[
'public_safety',
'police',
'fire',
'medical',
'infrastructure',
'evacuation',
].map((key) => `categories.${key}`),
...['title', 'body', 'offDuty', 'unavailable'].map(
(key) => `publisher.${key}`,
),
...[
'new',
'step',
'categoryTitle',
'categoryBody',
'severityTitle',
'areaTitle',
'areaBody',
'areaTypes.radius',
'areaTypes.district',
'areaTypes.city',
'areaLabel',
'areaPlaceholder',
'radius',
'useLocation',
'locationSet',
'contentTitle',
'title',
'titlePlaceholder',
'body',
'bodyPlaceholder',
'instructions',
'instructionsPlaceholder',
'duration',
'durationMinutes',
'previewTitle',
'recipients',
'legal',
'back',
'next',
'publish',
'publishing',
'success',
].map((key) => `compose.${key}`),
...[
'update',
'resolve',
'updateTitle',
'resolveTitle',
'updatePlaceholder',
'resolvePlaceholder',
'send',
'confirm',
'success',
'resolved',
].map((key) => `manage.${key}`),
...[
'locationTitle',
'locationBody',
'levelTitle',
'levelBody',
'categoryTitle',
'categoryBody',
'notificationHint',
].map((key) => `settings.${key}`),
...['published', 'update', 'resolved'].map(
(key) => `notifications.${key}`,
),
...[
'feature_disabled',
'not_authorized',
'invalid_warning',
'invalid_update',
'active_limit',
'revision_conflict',
'rate_limited',
'request_failed',
'not_found',
'device_not_open',
'device_not_owned',
'device_locked',
'default',
].map((key) => `errors.${key}`),
]
for (const key of cityWarnKeys) {
const path = `Apps.citywarn.${key}`
expect(phone.t(path), path).not.toBe(path)
}
expect(phone.t('Apps.citywarn.compose.recipients', { count: '42' })).toBe(
'About 42 people are currently reachable.',
)
})
it('uses the English Lua payload before the bundled emergency fallback', () => {
const phone = usePhoneStore()
phone.open({
+139
View File
@@ -658,8 +658,147 @@ const cryptoFallbackLocales = {
},
}
const citywarnFallbackLocales = {
name: 'CityWarn',
navigation: 'CityWarn navigation',
loading: 'Loading warnings...',
tabs: {
active: 'Current',
map: 'Map',
archive: 'History',
settings: 'Settings',
},
hero: {
safe: 'No immediate danger',
safeBody: 'There are no active warnings for your location.',
active: '{count} active warnings',
activeBody: 'Follow the guidance from the responsible authorities.',
},
issuedBy: 'Issued by',
updated: 'Updated',
expires: 'Valid until',
affectedArea: 'Affected area',
currentLocation: 'Current location',
details: 'Warning details',
instructions: 'What you should do now',
timeline: 'Updates',
publishedBy: 'Published by {name}',
radius: '{count} m radius',
status: {
active: 'Active',
resolved: 'Resolved',
expired: 'Expired',
},
severity: {
information: 'Information',
warning: 'Warning',
danger: 'Danger',
extreme: 'Extreme danger',
},
categories: {
public_safety: 'Public safety',
police: 'Police incident',
fire: 'Fire & rescue',
medical: 'Health',
infrastructure: 'Infrastructure',
evacuation: 'Evacuation',
},
emptyArchive: 'No history yet',
emptyArchiveBody: 'Resolved and expired warnings will appear here.',
emptyFiltered: 'No matching warnings',
emptyFilteredBody: 'Your personal filters hide the current alerts.',
mapTitle: 'Warning areas',
mapBody: 'Highlighted areas show the approximate scope of active alerts.',
publisher: {
title: 'Authority tools',
body: 'You are on duty as {job} ({grade}) and may publish warnings.',
offDuty: 'You must be on duty to publish warnings.',
unavailable: 'Authority tools are not available for your current job.',
},
compose: {
new: 'New warning',
step: 'Step {step} of 4',
categoryTitle: 'Type of danger',
categoryBody: 'Choose the category that best matches the incident.',
severityTitle: 'Danger level',
areaTitle: 'Affected area',
areaBody: 'Define where this alert applies.',
areaTypes: {
radius: 'Radius',
district: 'District',
city: 'Whole city',
},
areaLabel: 'Area name',
areaPlaceholder: 'e.g. Mission Row',
radius: 'Radius in metres',
useLocation: 'Use current location',
locationSet: 'Location captured',
contentTitle: 'Warning content',
title: 'Short title',
titlePlaceholder: 'What happened?',
body: 'Description',
bodyPlaceholder: 'Describe the situation clearly and factually...',
instructions: 'Recommended action',
instructionsPlaceholder: 'What should residents do now?',
duration: 'Validity',
durationMinutes: '{count} minutes',
previewTitle: 'Review & send',
recipients: 'About {count} people are currently reachable.',
legal: 'Your name, job and every change are recorded permanently.',
back: 'Back',
next: 'Continue',
publish: 'Publish warning',
publishing: 'Publishing...',
success: 'The warning was published and distributed.',
},
manage: {
update: 'Send update',
resolve: 'Resolve warning',
updateTitle: 'Update situation',
resolveTitle: 'Resolve warning',
updatePlaceholder: 'What changed?',
resolvePlaceholder: 'Why has the warning ended?',
send: 'Publish update',
confirm: 'Resolve warning',
success: 'The warning was updated.',
resolved: 'The warning was resolved.',
},
settings: {
locationTitle: 'Location-based warnings',
locationBody:
'Shows alerts for radiuses and districts. City-wide alerts always remain visible.',
levelTitle: 'Minimum level',
levelBody: 'Alerts below this level are hidden from the current view.',
categoryTitle: 'Warning types',
categoryBody: 'Choose which types of alerts you want to see.',
notificationHint:
'Critical alerts may bypass focus modes. Manage sounds in the phone settings.',
},
notifications: {
published: 'New population warning',
update: 'A warning was updated',
resolved: 'A warning was resolved',
},
errors: {
feature_disabled: 'CityWarn is disabled on this server.',
not_authorized: 'You are not allowed to perform this action.',
invalid_warning: 'Check all warning details.',
invalid_update: 'Enter a valid update.',
active_limit: 'Too many warnings are already active.',
revision_conflict: 'The warning changed in the meantime. Reload it.',
rate_limited: 'Please wait before trying again.',
request_failed: 'CityWarn could not complete the request.',
not_found: 'This warning is no longer available.',
device_not_open: 'Open and unlock this phone before trying again.',
device_not_owned: 'This phone is no longer available.',
device_locked: 'Unlock this phone before trying again.',
default: 'CityWarn is temporarily unavailable.',
},
}
const defaultLocales: LocaleTree = {
Apps: {
citywarn: citywarnFallbackLocales,
crypto: cryptoFallbackLocales,
easyShare: {
name: 'EasyShare',
+92
View File
@@ -0,0 +1,92 @@
export type CityWarnCategory =
| 'public_safety'
| 'police'
| 'fire'
| 'medical'
| 'infrastructure'
| 'evacuation'
export type CityWarnSeverity = 'information' | 'warning' | 'danger' | 'extreme'
export type CityWarnStatus = 'active' | 'resolved' | 'expired'
export type CityWarnAreaType = 'radius' | 'district' | 'city'
export type CityWarnUpdateKind = 'published' | 'update' | 'resolved'
export type CityWarnArea = {
centerX: number | null
centerY: number | null
label: string
radius: number | null
type: CityWarnAreaType
}
export type CityWarnUpdate = {
actorName: string
createdAt: number
id: string
kind: CityWarnUpdateKind
message: string
}
export type CityWarnAlert = {
area: CityWarnArea
authorName: string
body: string
category: CityWarnCategory
createdAt: number
expiresAt: number
id: string
instructions: string
revision: number
severity: CityWarnSeverity
sourceLabel: string
startsAt: number
status: CityWarnStatus
title: string
updatedAt: number
updates: CityWarnUpdate[]
}
export type CityWarnPublisherContext = {
allowedCategories: CityWarnCategory[]
canCityWide: boolean
canPublish: boolean
gradeLabel: string | null
jobLabel: string | null
maximumSeverity: CityWarnSeverity | null
onDuty: boolean
requiresDuty: boolean
}
export type CityWarnBootstrap = {
active: CityWarnAlert[]
archive: CityWarnAlert[]
context: CityWarnPublisherContext
onlinePlayers: number
}
export type CityWarnPreferences = {
categories: Record<CityWarnCategory, boolean>
locationAlerts: boolean
minimumSeverity: CityWarnSeverity
}
export type CityWarnPublishInput = {
area: CityWarnArea
body: string
category: CityWarnCategory
durationMinutes: number
instructions: string
severity: CityWarnSeverity
title: string
}
export type CityWarnEventData = {
alert?: CityWarnAlert
alertId?: string
kind?: CityWarnUpdateKind
severity?: CityWarnSeverity
sourceLabel?: string
text?: string
title?: string
}
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./CityWarnApp.vue', import.meta.url),
'utf8',
)
const server = readFileSync(
new URL('../../../../sky_phone/source/server/citywarn.lua', import.meta.url),
'utf8',
)
const store = readFileSync(
new URL('../../stores/citywarn.ts', import.meta.url),
'utf8',
)
const config = readFileSync(
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
'utf8',
)
describe('CityWarn product contract', () => {
it('uses central Sky navigation, scrolling, settings and sheets', () => {
expect(source).toContain('SkyPillNavigation')
expect(source).toContain('SkyScrollArea')
expect(source).toContain('SkySettingsGroup')
expect(source).toContain('SkySettingsRow')
expect(source).toContain('SkySheet')
expect(source).not.toContain("from 'konsta/vue'")
})
it('ships citizen feed, map, archive, settings and authority workflows', () => {
expect(source).toContain(
"type CityWarnTab = 'active' | 'map' | 'archive' | 'settings'",
)
expect(store).toContain("'citywarn:publish'")
expect(store).toContain("'citywarn:update'")
expect(store).toContain("'citywarn:resolve'")
expect(source).toContain('map:getPlayerCoords')
})
it('reserves the pill navigation, keeps sheets safe and avoids blur flicker', () => {
expect(source).toMatch(
/\.citywarn-scroll\.sky-scroll-area--tabbar\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 84px\)/s,
)
expect(source).toMatch(/\.citywarn-map\s*\{[^}]*height:\s*430px;/s)
expect(source).toMatch(
/\.citywarn-compose-sheet :deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*88%;[^}]*overflow:\s*hidden;/s,
)
expect(source).toContain('-webkit-backdrop-filter: none;')
expect(source).toContain('backdrop-filter: none;')
expect(source).toContain('filter: none !important;')
expect(source).toContain('will-change: auto !important;')
expect(source).not.toContain("t('manage.success')")
expect(source).not.toContain('<X :size="20" />')
expect(source).not.toContain('<span>{{ alert.title }}</span>')
})
it('keeps publishing authorization and validation on the server', () => {
expect(server).toContain('Bridge.Framework.GetJob(source)')
expect(server).toContain('SkyPhone.RequireSession(source)')
expect(server).toContain('config.RequireDuty and not on_duty')
expect(server).toContain('severity_rank[data.severity]')
expect(server).toContain('sky_phone_citywarn_updates')
expect(config).toContain('Config.CityWarn = {')
expect(config).toContain('Publishers = {')
})
})
File diff suppressed because it is too large Load Diff
+19
View File
@@ -1660,6 +1660,25 @@ Locales["de"] = {
},
errors = { profile_required = "Erstell zuerst dein Profil auf den lokalen Seiten.", invalid_profile = "Prüfe deinen Benutzernamen und deine Bio.", invalid_profile_image = "Wähle ein gültiges Foto von diesem Handy.", profile_handle_taken = "Dieser Benutzername ist bereits vergeben.", invalid_post = "Gib einen Titel und ein bisschen mehr Details.", invalid_images = "Wähle gültige Fotos von diesem Handy.", invalid_request = "Diese Aktion ist nicht gültig.", post_not_found = "Dieser Beitrag ist nicht mehr verfügbar.", citymarkt_not_found = "Dieses CityMarkt-Verzeichnis ist nicht verfügbar.", citymarkt_daily_limit = "Du hast heute schon eine CityMarkt-Liste geteilt.", citymarkt_already_shared = "Diese Auflistung wurde bereits geteilt.", not_authenticated = "Melde dich zuerst bei Sky Cloud an.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", request_failed = "Der Posten konnte nicht gerettet werden.", default = "Lokale Seiten sind vorübergehend nicht verfügbar." },
},
citywarn = {
name = "CityWarn", navigation = "CityWarn Navigation", loading = "Warnungen werden geladen...",
tabs = { active = "Aktuell", map = "Karte", archive = "Verlauf", settings = "Einstellungen" },
hero = { safe = "Keine akute Gefahr", safeBody = "Für deinen Standort liegen keine aktiven Warnungen vor.", active = "{count} aktive Warnungen", activeBody = "Beachte die Hinweise der zuständigen Stellen." },
issuedBy = "Herausgegeben von", updated = "Aktualisiert", expires = "Gültig bis", affectedArea = "Betroffenes Gebiet", currentLocation = "Aktueller Standort",
details = "Warnungsdetails", instructions = "Was du jetzt tun solltest", timeline = "Aktualisierungen", publishedBy = "Veröffentlicht von {name}",
radius = "{count} m Umkreis", status = { active = "Aktiv", resolved = "Aufgehoben", expired = "Abgelaufen" },
severity = { information = "Information", warning = "Warnung", danger = "Gefahr", extreme = "Extreme Gefahr" },
categories = { public_safety = "Öffentliche Sicherheit", police = "Polizeilage", fire = "Brand & Rettung", medical = "Gesundheit", infrastructure = "Infrastruktur", evacuation = "Evakuierung" },
emptyArchive = "Noch kein Verlauf", emptyArchiveBody = "Aufgehobene und abgelaufene Warnungen erscheinen hier.",
emptyFiltered = "Keine passenden Warnungen", emptyFilteredBody = "Deine persönlichen Filter blenden die aktuellen Meldungen aus.",
mapTitle = "Warngebiete", mapBody = "Die markierten Flächen zeigen den ungefähren Geltungsbereich aktiver Meldungen.",
publisher = { title = "Behördenbereich", body = "Du bist als {job} ({grade}) im Dienst und darfst Warnungen veröffentlichen.", offDuty = "Du musst im Dienst sein, um Warnungen zu veröffentlichen.", unavailable = "Für deinen aktuellen Job ist der Behördenbereich nicht freigeschaltet." },
compose = { new = "Neue Warnung", step = "Schritt {step} von 4", categoryTitle = "Art der Gefahr", categoryBody = "Wähle die Stelle, die am besten zur Lage passt.", severityTitle = "Gefahrenstufe", areaTitle = "Betroffenes Gebiet", areaBody = "Lege fest, für welchen Bereich die Meldung gilt.", areaTypes = { radius = "Umkreis", district = "Stadtteil", city = "Ganze Stadt" }, areaLabel = "Gebietsname", areaPlaceholder = "z. B. Mission Row", radius = "Radius in Metern", useLocation = "Aktuellen Standort verwenden", locationSet = "Standort übernommen", contentTitle = "Inhalt der Warnung", title = "Kurzer Titel", titlePlaceholder = "Was ist passiert?", body = "Beschreibung", bodyPlaceholder = "Beschreibe die Lage klar und sachlich...", instructions = "Handlungsempfehlung", instructionsPlaceholder = "Was sollen Einwohner jetzt tun?", duration = "Gültigkeit", durationMinutes = "{count} Minuten", previewTitle = "Prüfen & senden", recipients = "Etwa {count} Personen sind aktuell erreichbar.", legal = "Dein Name, Job und jede Änderung werden dauerhaft protokolliert.", back = "Zurück", next = "Weiter", publish = "Warnung veröffentlichen", publishing = "Wird veröffentlicht...", success = "Die Warnung wurde veröffentlicht und verteilt." },
manage = { update = "Update senden", resolve = "Warnung aufheben", updateTitle = "Lage aktualisieren", resolveTitle = "Warnung aufheben", updatePlaceholder = "Was hat sich geändert?", resolvePlaceholder = "Warum ist die Warnung beendet?", send = "Update veröffentlichen", confirm = "Warnung aufheben", success = "Die Warnung wurde aktualisiert.", resolved = "Die Warnung wurde aufgehoben." },
settings = { locationTitle = "Standortbezogene Warnungen", locationBody = "Zeigt Meldungen für Umkreise und Stadtteile. Stadtweite Warnungen bleiben immer sichtbar.", levelTitle = "Mindeststufe", levelBody = "Meldungen unter dieser Stufe werden in der aktuellen Ansicht ausgeblendet.", categoryTitle = "Warnarten", categoryBody = "Wähle, welche Arten von Meldungen du sehen möchtest.", notificationHint = "Kritische Warnungen können Fokus-Modi übergehen. Töne verwaltest du in den Telefon-Einstellungen." },
notifications = { published = "Neue Bevölkerungswarnung", update = "Eine Warnung wurde aktualisiert", resolved = "Eine Warnung wurde aufgehoben" },
errors = { feature_disabled = "CityWarn ist auf diesem Server deaktiviert.", not_authorized = "Du darfst diese Aktion nicht ausführen.", invalid_warning = "Prüfe alle Angaben der Warnung.", invalid_update = "Gib ein gültiges Update ein.", active_limit = "Es sind bereits zu viele Warnungen aktiv.", revision_conflict = "Die Warnung wurde zwischenzeitlich geändert. Lade sie neu.", rate_limited = "Bitte warte kurz, bevor du es erneut versuchst.", request_failed = "CityWarn konnte die Anfrage nicht abschließen.", not_found = "Diese Warnung ist nicht mehr verfügbar.", device_not_open = "Öffne und entsperre dieses Telefon, bevor du es erneut versuchst.", device_not_owned = "Dieses Telefon ist nicht mehr verfügbar.", device_locked = "Entsperre dieses Telefon, bevor du es erneut versuchst.", default = "CityWarn ist vorübergehend nicht verfügbar." },
},
map = {
name = "Karte", controls = "Kartensteuerung", currentLocation = "Aktueller Standort",
locationUnavailable = "Dein aktueller Standort ist nicht verfügbar.",
+19
View File
@@ -1660,6 +1660,25 @@ Locales["en"] = {
},
errors = { profile_required = "Create your Local Pages profile first.", invalid_profile = "Check your username and bio.", invalid_profile_image = "Choose a valid photo from this phone.", profile_handle_taken = "This username is already taken.", invalid_post = "Add a title and a little more detail.", invalid_images = "Choose valid photos from this phone.", invalid_request = "This action is not valid.", post_not_found = "This post is no longer available.", citymarkt_not_found = "This CityMarkt listing is unavailable.", citymarkt_daily_limit = "You already shared a CityMarkt listing today.", citymarkt_already_shared = "This listing was already shared.", not_authenticated = "Sign in to Sky Cloud first.", rate_limited = "Too many requests. Try again shortly.", request_failed = "The post could not be saved.", default = "Local Pages is temporarily unavailable." },
},
citywarn = {
name = "CityWarn", navigation = "CityWarn navigation", loading = "Loading warnings...",
tabs = { active = "Current", map = "Map", archive = "History", settings = "Settings" },
hero = { safe = "No immediate danger", safeBody = "There are no active warnings for your location.", active = "{count} active warnings", activeBody = "Follow the guidance from the responsible authorities." },
issuedBy = "Issued by", updated = "Updated", expires = "Valid until", affectedArea = "Affected area", currentLocation = "Current location",
details = "Warning details", instructions = "What you should do now", timeline = "Updates", publishedBy = "Published by {name}",
radius = "{count} m radius", status = { active = "Active", resolved = "Resolved", expired = "Expired" },
severity = { information = "Information", warning = "Warning", danger = "Danger", extreme = "Extreme danger" },
categories = { public_safety = "Public safety", police = "Police incident", fire = "Fire & rescue", medical = "Health", infrastructure = "Infrastructure", evacuation = "Evacuation" },
emptyArchive = "No history yet", emptyArchiveBody = "Resolved and expired warnings will appear here.",
emptyFiltered = "No matching warnings", emptyFilteredBody = "Your personal filters hide the current alerts.",
mapTitle = "Warning areas", mapBody = "Highlighted areas show the approximate scope of active alerts.",
publisher = { title = "Authority tools", body = "You are on duty as {job} ({grade}) and may publish warnings.", offDuty = "You must be on duty to publish warnings.", unavailable = "Authority tools are not available for your current job." },
compose = { new = "New warning", step = "Step {step} of 4", categoryTitle = "Type of danger", categoryBody = "Choose the category that best matches the incident.", severityTitle = "Danger level", areaTitle = "Affected area", areaBody = "Define where this alert applies.", areaTypes = { radius = "Radius", district = "District", city = "Whole city" }, areaLabel = "Area name", areaPlaceholder = "e.g. Mission Row", radius = "Radius in metres", useLocation = "Use current location", locationSet = "Location captured", contentTitle = "Warning content", title = "Short title", titlePlaceholder = "What happened?", body = "Description", bodyPlaceholder = "Describe the situation clearly and factually...", instructions = "Recommended action", instructionsPlaceholder = "What should residents do now?", duration = "Validity", durationMinutes = "{count} minutes", previewTitle = "Review & send", recipients = "About {count} people are currently reachable.", legal = "Your name, job and every change are recorded permanently.", back = "Back", next = "Continue", publish = "Publish warning", publishing = "Publishing...", success = "The warning was published and distributed." },
manage = { update = "Send update", resolve = "Resolve warning", updateTitle = "Update situation", resolveTitle = "Resolve warning", updatePlaceholder = "What changed?", resolvePlaceholder = "Why has the warning ended?", send = "Publish update", confirm = "Resolve warning", success = "The warning was updated.", resolved = "The warning was resolved." },
settings = { locationTitle = "Location-based warnings", locationBody = "Shows alerts for radiuses and districts. City-wide alerts always remain visible.", levelTitle = "Minimum level", levelBody = "Alerts below this level are hidden from the current view.", categoryTitle = "Warning types", categoryBody = "Choose which types of alerts you want to see.", notificationHint = "Critical alerts may bypass focus modes. Manage sounds in the phone settings." },
notifications = { published = "New population warning", update = "A warning was updated", resolved = "A warning was resolved" },
errors = { feature_disabled = "CityWarn is disabled on this server.", not_authorized = "You are not allowed to perform this action.", invalid_warning = "Check all warning details.", invalid_update = "Enter a valid update.", active_limit = "Too many warnings are already active.", revision_conflict = "The warning changed in the meantime. Reload it.", rate_limited = "Please wait before trying again.", request_failed = "CityWarn could not complete the request.", not_found = "This warning is no longer available.", device_not_open = "Open and unlock this phone before trying again.", device_not_owned = "This phone is no longer available.", device_locked = "Unlock this phone before trying again.", default = "CityWarn is temporarily unavailable." },
},
map = {
name = "Map", controls = "Map controls", currentLocation = "Current Location",
locationUnavailable = "Your current location is unavailable.",