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:
parent
6ba35add2f
commit
c1ac68e746
21 changed files with 469 additions and 34 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
43
app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt
Normal file
43
app/src/main/java/no/naiv/tilfluktsrom/ui/AboutDialog.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
104
app/src/main/res/layout/dialog_about.xml
Normal file
104
app/src/main/res/layout/dialog_about.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">ℹ</button>
|
||||
<button id="refresh-btn" aria-label="Refresh data">↻</button>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
5
pwa/src/cache/map-cache-manager.ts
vendored
5
pwa/src/cache/map-cache-manager.ts
vendored
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
92
pwa/src/ui/about-dialog.ts
Normal file
92
pwa/src/ui/about-dialog.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ export default defineConfig({
|
|||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200],
|
||||
statuses: [200],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue