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
|
|
@ -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