Legg til om-side, personvernerklæring og sikkerheitsforbetring

Om-side (Android + PWA):
- Ny AboutDialog med personvernerklæring, datakjelder og opphavsrett
- Opphavsrett flytta frå sivilforsvardialogen til om-sida
- Tilgjengeleg via «Om denne appen»-lenke i sivilforsvarsdialogen (Android)
  og ny om-knapp i statuslinja (PWA)
- Lokalisert til en/nb/nn

Personvern og sikkerheit:
- Lagra GPS-posisjon utløper etter 24 timar (widget_prefs)
- Widget viser «Trykk for å oppdatere» når posisjon manglar eller er utløpt
- Eigendefinert User-Agent (Tilfluktsrom/1.6.1) i OkHttp
- Content Security Policy (CSP) meta-tag i PWA
- Tenararbeidar bufrar berre HTTP 200-svar (ikkje opake)
- Kartbuffer-metadata runda til ~11km presisjon i localStorage
- crossorigin="anonymous" på Leaflet CSS

i18n-opprydding:
- Unicode-escapes erstatta med UTF-8-teikn i nb.ts og nn.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-23 14:27:45 +01:00
commit c1ac68e746
21 changed files with 469 additions and 34 deletions

View file

@ -211,9 +211,12 @@ class ShelterWidgetProvider : AppWidgetProvider() {
return getSavedLocation(context)
}
/** Returns null if older than 24 hours to avoid retaining stale location data. */
private fun getSavedLocation(context: Context): Location? {
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()

View file

@ -69,9 +69,12 @@ class WidgetUpdateWorker(
return Result.success()
}
/** Returns null if older than 24 hours. */
private fun getSavedLocation(): Location? {
val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()

View file

@ -7,6 +7,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
@ -43,6 +44,11 @@ class ShelterRepository(private val context: Context) {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(Interceptor { chain ->
chain.proceed(chain.request().newBuilder()
.header("User-Agent", "Tilfluktsrom/1.6.1")
.build())
})
.build()
/** Reactive stream of all shelters from local cache. */

View file

@ -0,0 +1,43 @@
package no.naiv.tilfluktsrom.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.fragment.app.DialogFragment
import no.naiv.tilfluktsrom.R
/**
* Full-screen dialog showing app info, privacy statement, and copyright.
* Static content all text comes from string resources for offline use and i18n.
*/
class AboutDialog : DialogFragment() {
companion object {
const val TAG = "AboutDialog"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.Theme_Tilfluktsrom_Dialog)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.dialog_about, container, false)
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
}
}

View file

@ -1,6 +1,5 @@
package no.naiv.tilfluktsrom.ui
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -32,6 +31,13 @@ class CivilDefenseInfoDialog : DialogFragment() {
return inflater.inflate(R.layout.dialog_civil_defense, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<View>(R.id.aboutLink)?.setOnClickListener {
AboutDialog().show(parentFragmentManager, AboutDialog.TAG)
}
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/status_bar_bg"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Title -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_title"
android:textColor="@color/shelter_primary"
android:textSize="20sp"
android:textStyle="bold" />
<!-- App description -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_description"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Privacy section -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/about_privacy_title"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_privacy_body"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Data sources section -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/about_data_title"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/about_data_body"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Stored on device section -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/about_stored_title"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/about_stored_body"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<!-- Copyright -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_copyright"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
<!-- Open source -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/about_open_source"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
</LinearLayout>
</ScrollView>

View file

@ -114,14 +114,16 @@
android:textSize="12sp"
android:textStyle="italic" />
<!-- Copyright notice -->
<!-- About link -->
<TextView
android:id="@+id/aboutLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/app_copyright"
android:textColor="@color/text_secondary"
android:textSize="11sp" />
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:text="@string/action_about"
android:textColor="@color/shelter_primary"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>

View file

@ -85,6 +85,18 @@
<string name="civil_defense_step5_body">Én sammenhengende tone på omtrent 30 sekunder. Faren eller angrepet er over. Fortsett å følge instruksjoner fra myndighetene.</string>
<string name="civil_defense_source">Kilde: DSB (Direktoratet for samfunnssikkerhet og beredskap)</string>
<!-- Om -->
<string name="about_title">Om Tilfluktsrom</string>
<string name="about_description">Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen er laget for å fungere uten internett etter første oppsett — du trenger ikke nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din.</string>
<string name="about_privacy_title">Personvern</string>
<string name="about_privacy_body">Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester.\n\nGPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server.</string>
<string name="about_data_title">Datakilder</string>
<string name="about_data_body">Tilfluktsromdata er offentlig informasjon fra Geonorge (Kartverket). Kartfliser lastes fra OpenStreetMap. Begge lagres lokalt for frakoblet bruk.</string>
<string name="about_stored_title">Lagret på enheten din</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlige data fra Geonorge)\n• Kartfliser for frakoblet bruk\n• Din siste GPS-posisjon (for hjemmeskjerm-widgeten)\n\nIngen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.</string>
<string name="about_open_source">Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string>
<!-- Opphavsrett -->
<string name="app_copyright">Opphavsrett © Ole-Morten Duesund</string>
</resources>

View file

@ -85,6 +85,18 @@
<string name="civil_defense_step5_body">Éin samanhengande tone på omtrent 30 sekund. Faren eller åtaket er over. Hald fram med å følgje instruksjonar frå styresmaktene.</string>
<string name="civil_defense_source">Kjelde: DSB (Direktoratet for samfunnstryggleik og beredskap)</string>
<!-- Om -->
<string name="about_title">Om Tilfluktsrom</string>
<string name="about_description">Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen er laga for å fungere utan internett etter fyrste oppsett — du treng ikkje nett for å finne tilfluktsrom, navigere med kompass eller dele posisjonen din.</string>
<string name="about_privacy_title">Personvern</string>
<string name="about_privacy_body">Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester.\n\nGPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar.</string>
<string name="about_data_title">Datakjelder</string>
<string name="about_data_body">Tilfluktsromdata er offentleg informasjon frå Geonorge (Kartverket). Kartfliser vert lasta frå OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.</string>
<string name="about_stored_title">Lagra på eininga di</string>
<string name="about_stored_body">• Tilfluktsromdatabase (offentlege data frå Geonorge)\n• Kartfliser for fråkopla bruk\n• Din siste GPS-posisjon (for heimeskjerm-widgeten)\n\nIngen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.</string>
<string name="about_open_source">Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">Om denne appen</string>
<!-- Opphavsrett -->
<string name="app_copyright">Opphavsrett © Ole-Morten Duesund</string>
</resources>

View file

@ -85,6 +85,18 @@
<string name="civil_defense_step5_body">One continuous tone lasting approximately 30 seconds. The danger or attack is over. Continue to follow instructions from authorities.</string>
<string name="civil_defense_source">Source: DSB (Norwegian Directorate for Civil Protection)</string>
<!-- About -->
<string name="about_title">About Tilfluktsrom</string>
<string name="about_description">Tilfluktsrom helps you find the nearest public shelter in Norway. The app is designed to work offline after initial setup — no internet required to find shelters, navigate by compass, or share your location.</string>
<string name="about_privacy_title">Privacy</string>
<string name="about_privacy_body">This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services.\n\nYour GPS location is used only on your device to find nearby shelters and is never sent to any server.</string>
<string name="about_data_title">Data sources</string>
<string name="about_data_body">Shelter data is public information from Geonorge (Norwegian Mapping Authority). Map tiles are loaded from OpenStreetMap. Both are cached locally for offline use.</string>
<string name="about_stored_title">Stored on your device</string>
<string name="about_stored_body">• Shelter database (public data from Geonorge)\n• Map tiles for offline use\n• Your last GPS position (for the home screen widget)\n\nNo data leaves your device except requests to download shelter data and map tiles.</string>
<string name="about_open_source">Open source — kode.naiv.no/olemd/tilfluktsrom</string>
<string name="action_about">About this app</string>
<!-- Copyright -->
<string name="app_copyright">Copyright © Ole-Morten Duesund</string>
</resources>

View file

@ -252,10 +252,13 @@ class ShelterWidgetProvider : AppWidgetProvider() {
return getSavedLocation(context)
}
/** Read the last GPS fix persisted by MainActivity to SharedPreferences. */
/** Read the last GPS fix persisted by MainActivity to SharedPreferences.
* Returns null if older than 24 hours to avoid retaining stale location data. */
private fun getSavedLocation(context: Context): Location? {
val prefs = context.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()

View file

@ -84,10 +84,13 @@ class WidgetUpdateWorker(
return Result.success()
}
/** Read the last GPS fix persisted by MainActivity. */
/** Read the last GPS fix persisted by MainActivity.
* Returns null if older than 24 hours. */
private fun getSavedLocation(): Location? {
val prefs = applicationContext.getSharedPreferences("widget_prefs", Context.MODE_PRIVATE)
if (!prefs.contains("last_lat")) return null
val age = System.currentTimeMillis() - prefs.getLong("last_time", 0L)
if (age > 24 * 60 * 60 * 1000L) return null
return Location("saved").apply {
latitude = prefs.getFloat("last_lat", 0f).toDouble()
longitude = prefs.getFloat("last_lon", 0f).toDouble()

View file

@ -5,19 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1A1A2E" />
<meta name="description" content="Find the nearest public shelter in Norway" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com; connect-src 'self' https://*.tile.openstreetmap.org; font-src 'self'; worker-src 'self'" />
<title>Tilfluktsrom</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
crossorigin="anonymous" />
</head>
<body>
<div id="app">
<!-- Status bar -->
<header id="status-bar" role="banner">
<span id="status-text" aria-live="polite"></span>
<button id="about-btn" aria-label="About">&#x2139;</button>
<button id="refresh-btn" aria-label="Refresh data">&#x21bb;</button>
</header>

View file

@ -20,6 +20,7 @@ import * as shelterList from './ui/shelter-list';
import * as statusBar from './ui/status-bar';
import * as loading from './ui/loading-overlay';
import * as mapCache from './cache/map-cache-manager';
import * as aboutDialog from './ui/about-dialog';
const NEAREST_COUNT = 3;
@ -44,8 +45,13 @@ export async function init(): Promise<void> {
await loadData();
}
/** Set localized aria-labels on landmark elements. */
/** Set localized aria-labels and wire the about button. */
function applyA11yLabels(): void {
document.getElementById('about-btn')?.setAttribute('aria-label', t('action_about'));
document.getElementById('about-btn')?.addEventListener('click', () => {
navigator.vibrate?.(10);
aboutDialog.showAbout();
});
document.getElementById('map-container')?.setAttribute('aria-label', t('a11y_map'));
document.getElementById('compass-container')?.setAttribute('aria-label', t('a11y_compass'));
document.getElementById('bottom-sheet')?.setAttribute('aria-label', t('a11y_shelter_info'));

View file

@ -94,9 +94,10 @@ export async function cacheMapArea(
// Restore original view
map.setView(originalCenter, originalZoom, { animate: false });
// Round coordinates to 1 decimal (~11km) to limit location precision in storage
saveCacheMeta({
lat,
lon,
lat: Math.round(lat * 10) / 10,
lon: Math.round(lon * 10) / 10,
radius: CACHE_RADIUS_DEGREES,
complete: true,
});

View file

@ -53,4 +53,22 @@ export const en: Record<string, string> = {
a11y_compass: 'Compass',
a11y_shelter_info: 'Shelter info',
a11y_nearest_shelters: 'Nearest shelters',
// About
about_title: 'About Tilfluktsrom',
about_description:
'Tilfluktsrom helps you find the nearest public shelter in Norway. The app works offline after initial setup.',
about_privacy_title: 'Privacy',
about_privacy_body:
'This app does not collect, transmit, or share any personal data. There are no analytics, tracking, or third-party services. Your GPS location is used only on your device to find nearby shelters and is never sent to any server.',
about_data_title: 'Data sources',
about_data_body:
'Shelter data: Geonorge (Norwegian Mapping Authority). Map tiles: OpenStreetMap. Both are cached locally for offline use.',
about_stored_title: 'Stored on your device',
about_stored_body:
'Shelter database (public data), map tiles for offline use, and map cache metadata. No data leaves your device except requests to download shelter data and map tiles.',
about_copyright: 'Copyright © Ole-Morten Duesund',
about_open_source: 'Open source — kode.naiv.no/olemd/tilfluktsrom',
action_about: 'About',
action_close: 'Close',
};

View file

@ -1,4 +1,4 @@
/** Norwegian Bokm\u00e5l strings. Ported from res/values-nb/strings.xml. */
/** Norwegian Bokmål strings. Ported from res/values-nb/strings.xml. */
export const nb: Record<string, string> = {
app_name: 'Tilfluktsrom',
@ -7,40 +7,40 @@ export const nb: Record<string, string> = {
status_updating: 'Oppdaterer\u2026',
status_offline: 'Frakoblet modus',
status_shelters_loaded: '%d tilfluktsrom lastet',
status_no_location: 'Venter p\u00e5 GPS\u2026',
status_no_location: 'Venter på GPS\u2026',
status_caching_map: 'Lagrer kart for frakoblet bruk\u2026',
loading_shelters: 'Laster ned tilfluktsromdata\u2026',
loading_map: 'Lagrer kartfliser\u2026',
loading_map_explanation:
'Forbereder frakoblet kart.\nKartet vil rulle kort for \u00e5 lagre omgivelsene dine.',
loading_first_time: 'Gj\u00f8r klar for f\u00f8rste gangs bruk\u2026',
'Forbereder frakoblet kart.\nKartet vil rulle kort for å lagre omgivelsene dine.',
loading_first_time: 'Gjør klar for første gangs bruk\u2026',
shelter_capacity: '%d plasser',
shelter_room_nr: 'Rom %d',
nearest_shelter: 'N\u00e6rmeste tilfluktsrom',
nearest_shelter: 'Nærmeste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengelig',
action_refresh: 'Oppdater data',
action_toggle_view: 'Bytt mellom kart og kompassvisning',
action_skip: 'Hopp over',
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre n\u00e5',
action_cache_now: 'Lagre nå',
warning_no_map_cache:
'Ingen frakoblet kart lagret. Kartet krever internett.',
permission_location_title: 'Posisjonstillatelse kreves',
permission_location_message:
'Denne appen trenger din posisjon for \u00e5 finne n\u00e6rmeste tilfluktsrom. Vennligst gi tilgang til posisjon.',
'Denne appen trenger din posisjon for å finne nærmeste tilfluktsrom. Vennligst gi tilgang til posisjon.',
permission_denied:
'Posisjonstillatelse avsl\u00e5tt. Appen kan ikke finne tilfluktsrom i n\u00e6rheten uten den.',
'Posisjonstillatelse avslått. Appen kan ikke finne tilfluktsrom i nærheten uten den.',
error_download_failed:
'Kunne ikke laste ned tilfluktsromdata. Sjekk internettforbindelsen.',
error_no_data_offline:
'Ingen lagrede data tilgjengelig. Koble til internett for \u00e5 laste ned tilfluktsromdata.',
'Ingen lagrede data tilgjengelig. Koble til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislyktes \u2014 bruker lagrede data',
update_failed: 'Oppdatering mislyktes bruker lagrede data',
// Tilgjengelighet
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
@ -48,4 +48,22 @@ export const nb: Record<string, string> = {
a11y_compass: 'Kompass',
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmeste tilfluktsrom',
// Om
about_title: 'Om Tilfluktsrom',
about_description:
'Tilfluktsrom hjelper deg med å finne nærmeste offentlige tilfluktsrom i Norge. Appen fungerer uten internett etter første oppsett.',
about_privacy_title: 'Personvern',
about_privacy_body:
'Denne appen samler ikke inn, sender eller deler noen personopplysninger. Det finnes ingen analyse, sporing eller tredjepartstjenester. GPS-posisjonen din brukes bare lokalt på enheten din for å finne tilfluktsrom i nærheten, og sendes aldri til noen server.',
about_data_title: 'Datakilder',
about_data_body:
'Tilfluktsromdata: Geonorge (Kartverket). Kartfliser: OpenStreetMap. Begge lagres lokalt for frakoblet bruk.',
about_stored_title: 'Lagret på enheten din',
about_stored_body:
'Tilfluktsromdatabase (offentlige data), kartfliser for frakoblet bruk og kartbuffer-metadata. Ingen data forlater enheten din bortsett fra forespørsler om å laste ned tilfluktsromdata og kartfliser.',
about_copyright: 'Opphavsrett © Ole-Morten Duesund',
about_open_source: 'Åpen kildekode — kode.naiv.no/olemd/tilfluktsrom',
action_about: 'Om',
action_close: 'Lukk',
};

View file

@ -5,20 +5,20 @@ export const nn: Record<string, string> = {
status_ready: 'Klar',
status_loading: 'Lastar tilfluktsromdata\u2026',
status_updating: 'Oppdaterer\u2026',
status_offline: 'Fr\u00e5kopla modus',
status_offline: 'Fråkopla modus',
status_shelters_loaded: '%d tilfluktsrom lasta',
status_no_location: 'Ventar p\u00e5 GPS\u2026',
status_caching_map: 'Lagrar kart for fr\u00e5kopla bruk\u2026',
status_no_location: 'Ventar på GPS\u2026',
status_caching_map: 'Lagrar kart for fråkopla bruk\u2026',
loading_shelters: 'Lastar ned tilfluktsromdata\u2026',
loading_map: 'Lagrar kartfliser\u2026',
loading_map_explanation:
'F\u00f8rebur fr\u00e5kopla kart.\nKartet vil rulle kort for \u00e5 lagre omgjevnadene dine.',
'Førebur fråkopla kart.\nKartet vil rulle kort for å lagre omgjevnadene dine.',
loading_first_time: 'Gjer klar for fyrste gongs bruk\u2026',
shelter_capacity: '%d plassar',
shelter_room_nr: 'Rom %d',
nearest_shelter: 'N\u00e6raste tilfluktsrom',
nearest_shelter: 'Næraste tilfluktsrom',
no_shelters: 'Ingen tilfluktsromdata tilgjengeleg',
action_refresh: 'Oppdater data',
@ -27,20 +27,20 @@ export const nn: Record<string, string> = {
action_cache_ok: 'Lagre kart',
action_cache_now: 'Lagre no',
warning_no_map_cache:
'Ingen fr\u00e5kopla kart lagra. Kartet krev internett.',
'Ingen fråkopla kart lagra. Kartet krev internett.',
permission_location_title: 'Posisjonsløyve krevst',
permission_location_message:
'Denne appen treng posisjonen din for \u00e5 finne n\u00e6raste tilfluktsrom. Ver venleg og gje tilgang til posisjon.',
'Denne appen treng posisjonen din for å finne næraste tilfluktsrom. Ver venleg og gje tilgang til posisjon.',
permission_denied:
'Posisjonsløyve avsl\u00e5tt. Appen kan ikkje finne tilfluktsrom i n\u00e6rleiken utan det.',
'Posisjonsløyve avslått. Appen kan ikkje finne tilfluktsrom i nærleiken utan det.',
error_download_failed:
'Kunne ikkje laste ned tilfluktsromdata. Sjekk internettilkoplinga.',
error_no_data_offline:
'Ingen lagra data tilgjengeleg. Kopla til internett for \u00e5 laste ned tilfluktsromdata.',
'Ingen lagra data tilgjengeleg. Kopla til internett for å laste ned tilfluktsromdata.',
update_success: 'Tilfluktsromdata oppdatert',
update_failed: 'Oppdatering mislukkast \u2014 brukar lagra data',
update_failed: 'Oppdatering mislukkast brukar lagra data',
// Tilgjenge
direction_arrow_description: 'Retning til tilfluktsrom, %s unna',
@ -48,4 +48,22 @@ export const nn: Record<string, string> = {
a11y_compass: 'Kompass',
a11y_shelter_info: 'Tilfluktsrominfo',
a11y_nearest_shelters: 'Nærmaste tilfluktsrom',
// Om
about_title: 'Om Tilfluktsrom',
about_description:
'Tilfluktsrom hjelper deg med å finne næraste offentlege tilfluktsrom i Noreg. Appen fungerer utan internett etter fyrste oppsett.',
about_privacy_title: 'Personvern',
about_privacy_body:
'Denne appen samlar ikkje inn, sender eller deler nokon personopplysingar. Det finst ingen analyse, sporing eller tredjepartstenester. GPS-posisjonen din vert berre brukt lokalt på eininga di for å finne tilfluktsrom i nærleiken, og vert aldri sendt til nokon tenar.',
about_data_title: 'Datakjelder',
about_data_body:
'Tilfluktsromdata: Geonorge (Kartverket). Kartfliser: OpenStreetMap. Begge vert lagra lokalt for fråkopla bruk.',
about_stored_title: 'Lagra på eininga di',
about_stored_body:
'Tilfluktsromdatabase (offentlege data), kartfliser for fråkopla bruk og kartbuffer-metadata. Ingen data forlèt eininga di bortsett frå førespurnader om å laste ned tilfluktsromdata og kartfliser.',
about_copyright: 'Opphavsrett © Ole-Morten Duesund',
about_open_source: 'Open kjeldekode — kode.naiv.no/olemd/tilfluktsrom',
action_about: 'Om',
action_close: 'Lukk',
};

View file

@ -62,6 +62,7 @@ html, body {
text-overflow: ellipsis;
}
#about-btn,
#refresh-btn {
background: none;
border: none;
@ -73,6 +74,7 @@ html, body {
flex-shrink: 0;
}
#about-btn:hover,
#refresh-btn:hover {
color: #ECEFF1;
}
@ -399,3 +401,71 @@ html, body {
.leaflet-popup-close-button {
color: #CFD8DC !important;
}
/* --- About dialog --- */
#about-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.about-content {
background: #16213E;
border-radius: 12px;
padding: 24px;
max-width: 480px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.about-heading {
color: #FF6B35;
font-size: 20px;
font-weight: bold;
margin-bottom: 12px;
}
.about-subheading {
color: #ECEFF1;
font-size: 16px;
font-weight: bold;
margin-top: 16px;
margin-bottom: 4px;
}
.about-para {
color: #90A4AE;
font-size: 14px;
line-height: 1.5;
margin-bottom: 8px;
}
.about-footer {
margin-top: 20px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.about-small {
color: #90A4AE;
font-size: 11px;
margin-bottom: 2px;
}
.about-close-btn {
display: block;
margin: 16px auto 0;
padding: 10px 32px;
background: #FF6B35;
border: none;
border-radius: 6px;
color: #FFFFFF;
font-size: 14px;
cursor: pointer;
}

View file

@ -0,0 +1,92 @@
/**
* About dialog: app info, privacy statement, data sources, copyright.
* Opens as a modal overlay, same pattern as loading-overlay.
*/
import { t } from '../i18n/i18n';
let overlay: HTMLDivElement | null = null;
let previousFocus: HTMLElement | null = null;
/** Show the about dialog. */
export function showAbout(): void {
if (overlay) return;
previousFocus = document.activeElement as HTMLElement | null;
overlay = document.createElement('div');
overlay.id = 'about-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', t('about_title'));
const content = document.createElement('div');
content.className = 'about-content';
content.appendChild(heading(t('about_title')));
content.appendChild(para(t('about_description')));
content.appendChild(subheading(t('about_privacy_title')));
content.appendChild(para(t('about_privacy_body')));
content.appendChild(subheading(t('about_data_title')));
content.appendChild(para(t('about_data_body')));
content.appendChild(subheading(t('about_stored_title')));
content.appendChild(para(t('about_stored_body')));
const footer = document.createElement('div');
footer.className = 'about-footer';
footer.appendChild(small(t('about_copyright')));
footer.appendChild(small(t('about_open_source')));
content.appendChild(footer);
const closeBtn = document.createElement('button');
closeBtn.className = 'about-close-btn';
closeBtn.textContent = t('action_close');
closeBtn.addEventListener('click', hideAbout);
content.appendChild(closeBtn);
overlay.appendChild(content);
document.body.appendChild(overlay);
closeBtn.focus();
}
/** Hide the about dialog and restore focus. */
export function hideAbout(): void {
if (overlay) {
overlay.remove();
overlay = null;
}
previousFocus?.focus();
previousFocus = null;
}
function heading(text: string): HTMLElement {
const el = document.createElement('h2');
el.textContent = text;
el.className = 'about-heading';
return el;
}
function subheading(text: string): HTMLElement {
const el = document.createElement('h3');
el.textContent = text;
el.className = 'about-subheading';
return el;
}
function para(text: string): HTMLElement {
const el = document.createElement('p');
el.textContent = text;
el.className = 'about-para';
return el;
}
function small(text: string): HTMLElement {
const el = document.createElement('p');
el.textContent = text;
el.className = 'about-small';
return el;
}

View file

@ -39,7 +39,7 @@ export default defineConfig({
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
cacheableResponse: {
statuses: [0, 200],
statuses: [200],
},
},
},