From 742b2d2a0d9e6abfad9e8c56202eb5d9598c9f34 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Tue, 18 Aug 2026 15:36:17 +0200 Subject: [PATCH] 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. --- frontend/src/stores/citywarn.test.ts | 99 + frontend/src/stores/citywarn.ts | 195 ++ frontend/src/stores/phone-locales.test.ts | 127 ++ frontend/src/stores/phone.ts | 139 ++ frontend/src/types/citywarn.ts | 92 + .../views/apps/CityWarnApp.contract.test.ts | 68 + frontend/src/views/apps/CityWarnApp.vue | 1831 +++++++++++++++++ sky_phone/config/locales/de.lua | 19 + sky_phone/config/locales/en.lua | 19 + 9 files changed, 2589 insertions(+) create mode 100644 frontend/src/stores/citywarn.test.ts create mode 100644 frontend/src/stores/citywarn.ts create mode 100644 frontend/src/types/citywarn.ts create mode 100644 frontend/src/views/apps/CityWarnApp.contract.test.ts create mode 100644 frontend/src/views/apps/CityWarnApp.vue diff --git a/frontend/src/stores/citywarn.test.ts b/frontend/src/stores/citywarn.test.ts new file mode 100644 index 0000000..f8349c6 --- /dev/null +++ b/frontend/src/stores/citywarn.test.ts @@ -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 { + 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) + }) +}) diff --git a/frontend/src/stores/citywarn.ts b/frontend/src/stores/citywarn.ts new file mode 100644 index 0000000..ec46cc1 --- /dev/null +++ b/frontend/src/stores/citywarn.ts @@ -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 = { + 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, + locationAlerts: true, + minimumSeverity: 'information', +} + +function parsePreferences(value: unknown): CityWarnPreferences { + const candidate = + value && typeof value === 'object' + ? (value as Partial) + : {} + 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, + 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 { + this.isLoading = true + this.hydratePreferences() + const response = await nuiCall('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 { + 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> { + 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> { + 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> { + 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 + }, + }, +}) diff --git a/frontend/src/stores/phone-locales.test.ts b/frontend/src/stores/phone-locales.test.ts index 24039e1..f4f03ce 100644 --- a/frontend/src/stores/phone-locales.test.ts +++ b/frontend/src/stores/phone-locales.test.ts @@ -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({ diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 126133a..be23e88 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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', diff --git a/frontend/src/types/citywarn.ts b/frontend/src/types/citywarn.ts new file mode 100644 index 0000000..3ad300f --- /dev/null +++ b/frontend/src/types/citywarn.ts @@ -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 + 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 +} diff --git a/frontend/src/views/apps/CityWarnApp.contract.test.ts b/frontend/src/views/apps/CityWarnApp.contract.test.ts new file mode 100644 index 0000000..9321803 --- /dev/null +++ b/frontend/src/views/apps/CityWarnApp.contract.test.ts @@ -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('') + expect(source).not.toContain('{{ alert.title }}') + }) + + 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 = {') + }) +}) diff --git a/frontend/src/views/apps/CityWarnApp.vue b/frontend/src/views/apps/CityWarnApp.vue new file mode 100644 index 0000000..f69a705 --- /dev/null +++ b/frontend/src/views/apps/CityWarnApp.vue @@ -0,0 +1,1831 @@ + + + + + diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 579b8de..e341391 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -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.", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index e2c734d..65700f0 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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.",