feat: offline mode; PWA

This commit is contained in:
2025-04-28 00:10:44 +02:00
parent f4aa0b28a1
commit 4e8aabe05e
18 changed files with 6111 additions and 54 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-99d8380f'], (function (workbox) { 'use strict';
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "index.html",
"revision": "0.3s1lnfb7iao"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
self.__WB_DISABLE_DEV_LOGS = true;
}));
File diff suppressed because it is too large Load Diff
+1
View File
@@ -25,6 +25,7 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5", "vite": "^6.0.5",
"vite-plugin-pwa": "^1.0.0",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
} }
+52 -13
View File
@@ -1,5 +1,11 @@
<template> <template>
<div class="text-white min-h-screen bg-zinc-950"> <div class="text-white min-h-screen bg-zinc-950">
<!-- PWA update prompt -->
<transition name="slide-anim">
<UpdatePrompt v-if="needRefresh" @onUpdateClick="updateApp()" />
</transition>
<!-- Content -->
<Navbar /> <Navbar />
<MainContainer /> <MainContainer />
</div> </div>
@@ -8,10 +14,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import Navbar from './components/App/Navbar.vue'; import Navbar from './components/App/Navbar.vue';
import MainContainer from './components/App/MainContainer.vue'; import MainContainer from './components/App/MainContainer.vue';
import UpdatePrompt from './components/App/UpdatePrompt.vue';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useApiStore } from './stores/api.store'; import { useApiStore } from './stores/api.store';
import { useGlobalStore } from './stores/global.store'; import { useGlobalStore } from './stores/global.store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRegisterSW } from 'virtual:pwa-register/vue';
import { DataStatus } from './types/api.types';
const originalDocumentTitle = document.title; const originalDocumentTitle = document.title;
@@ -19,28 +29,24 @@ const apiStore = useApiStore();
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const i18n = useI18n(); const i18n = useI18n();
const { needRefresh, updateServiceWorker } = useRegisterSW({ immediate: true });
onMounted(async () => { onMounted(async () => {
setupLocale(); setupLocale();
setupDarkMode(); setupDarkMode();
setupOfflineMode();
loadStorageTimetables(); loadStorageTimetables();
setupAfterPrintClose(); setupAfterPrintClose();
await apiStore.setupAPIData(); await apiStore.setupAPIData();
handleQueries();
const query = new URLSearchParams(window.location.search);
if (query.has('id')) {
const id = query.get('id')!;
const queryTrain = apiStore.activeData?.trains.find((train) => train.id == id);
if (queryTrain) {
globalStore.selectedTrainId = id;
globalStore.selectedActiveTrain = queryTrain;
}
}
}); });
function updateApp() {
updateServiceWorker(true);
needRefresh.value = false;
}
function loadStorageTimetables() { function loadStorageTimetables() {
if (!window.localStorage.getItem('savedTimetables')) return; if (!window.localStorage.getItem('savedTimetables')) return;
@@ -73,4 +79,37 @@ function setupLocale() {
i18n.locale.value = window.localStorage.getItem('locale')!; i18n.locale.value = window.localStorage.getItem('locale')!;
} }
} }
function setupOfflineMode() {
apiStore.connectionMode = !navigator.onLine ? 'offline' : 'online';
window.addEventListener('offline', () => {
apiStore.connectionMode = 'offline';
apiStore.journalTimetablesData = null;
apiStore.activeData = null;
});
window.addEventListener('online', () => {
apiStore.connectionMode = 'online';
apiStore.journalDataStatus = DataStatus.SUCCESS;
apiStore.setupAPIData();
});
}
function handleQueries() {
const query = new URLSearchParams(window.location.search);
if (query.has('id')) {
const id = query.get('id')!;
const queryTrain = apiStore.activeData?.trains.find((train) => train.id == id);
if (queryTrain) {
globalStore.selectedTrainId = id;
globalStore.selectedActiveTrain = queryTrain;
}
}
}
</script> </script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="fixed z-50 bottom-0 right-0">
<button @click="onUpdateClick" class="p-3 m-3 bg-cyan-600 rounded-md text-xl" ref="updateBtnEl">
<div>{{ $t('update-prompt.line1') }}</div>
<u>{{ $t('update-prompt.line2') }}</u>
</button>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { ref } from 'vue';
const emit = defineEmits(['onUpdateClick']);
const updateBtnEl = ref<HTMLElement | null>(null);
function onUpdateClick() {
emit('onUpdateClick');
}
onMounted(() => {
updateBtnEl.value?.focus();
});
</script>
+7 -2
View File
@@ -99,9 +99,14 @@
<input <input
type="text" type="text"
v-else-if="globalStore.viewMode == 'journal'" v-else-if="globalStore.viewMode == 'journal'"
v-model="globalStore.journalTimetableSearch"
@change="fetchJournalTimetables" @change="fetchJournalTimetables"
class="bg-zinc-800 p-1 rounded-md print:hidden w-full" v-model="globalStore.journalTimetableSearch"
:class="`bg-zinc-800 p-1 rounded-md print:hidden w-full ${
apiStore.connectionMode == 'offline' ? 'opacity-35' : ''
}`"
:disabled="
apiStore.journalDataStatus == DataStatus.LOADING || apiStore.connectionMode == 'offline'
"
:placeholder="$t('journal-search-placeholder')" :placeholder="$t('journal-search-placeholder')"
/> />
</div> </div>
+2 -1
View File
@@ -18,7 +18,8 @@
<div class="overflow-auto text-center font-bold text-zinc-400 p-1 min-h-full" v-else> <div class="overflow-auto text-center font-bold text-zinc-400 p-1 min-h-full" v-else>
<div v-if="globalStore.viewMode == 'active'"> <div v-if="globalStore.viewMode == 'active'">
<div>{{ $t('train-select-info') }}</div> <div v-if="apiStore.connectionMode == 'online'">{{ $t('train-select-info') }}</div>
<div v-else class="bg-red-500 text-white p-2">{{ $t('data-offline-mode') }}</div>
</div> </div>
<LocalStorageView v-else-if="globalStore.viewMode == 'storage'" /> <LocalStorageView v-else-if="globalStore.viewMode == 'storage'" />
@@ -4,7 +4,11 @@
{{ $t('journal-preview-title') }} {{ $t('journal-preview-title') }}
</h2> </h2>
<div v-if="apiStore.journalDataStatus == DataStatus.LOADING" class="bg-zinc-900 p-2"> <div v-if="apiStore.connectionMode == 'offline'" class="bg-red-500 p-2">
{{ $t('data-offline-mode') }}
</div>
<div v-else-if="apiStore.journalDataStatus == DataStatus.LOADING" class="bg-zinc-900 p-2">
{{ $t('data-loading-text') }} {{ $t('data-loading-text') }}
</div> </div>
+9
View File
@@ -4,6 +4,14 @@
"train-select-placeholder": "Choose active train from the list", "train-select-placeholder": "Choose active train from the list",
"train-select-info": "Choose active train to generate SRJP timetable", "train-select-info": "Choose active train to generate SRJP timetable",
"train-search-placeholder": "Enter TT details (number, route, user)", "train-search-placeholder": "Enter TT details (number, route, user)",
"update-prompt": {
"line1": "New version of SRJP is available!",
"line2": "Click here to update the app!"
},
"data-offline-mode": "You're currently using the offline mode of the SRJP app - server data is unavailable!",
"headers": { "headers": {
"line_no": "Line\nno.", "line_no": "Line\nno.",
"line_km": "Km", "line_km": "Km",
@@ -17,6 +25,7 @@
"vmax": "Vmax", "vmax": "Vmax",
"relation": "Route" "relation": "Route"
}, },
"storage-empty-header": "ARCHIVED TIMETABLES SEARCH MODE", "storage-empty-header": "ARCHIVED TIMETABLES SEARCH MODE",
"storage-empty-info": "Timetables will be shown here after their archiving.", "storage-empty-info": "Timetables will be shown here after their archiving.",
"storage-preview-title": "ARCHIVED TIMETABLES", "storage-preview-title": "ARCHIVED TIMETABLES",
+9
View File
@@ -4,6 +4,14 @@
"train-select-placeholder": "Wybierz pociąg z listy", "train-select-placeholder": "Wybierz pociąg z listy",
"train-select-info": "Wybierz aktywny pociąg, aby wygenerować SRJP", "train-select-info": "Wybierz aktywny pociąg, aby wygenerować SRJP",
"train-search-placeholder": "Wpisz szczegóły RJ (nr, relacja, gracz)", "train-search-placeholder": "Wpisz szczegóły RJ (nr, relacja, gracz)",
"update-prompt": {
"line1": "Nowa wersja SRJP jest dostępna!",
"line2": "Kliknij, aby zaktualizować aplikację!"
},
"data-offline-mode": "Korzystasz z trybu offline aplikacji SRJP - dane serwerowe są niedostępne!",
"headers": { "headers": {
"line_no": "Nr\nlinii", "line_no": "Nr\nlinii",
"line_km": "Km", "line_km": "Km",
@@ -17,6 +25,7 @@
"vmax": "Vmax", "vmax": "Vmax",
"relation": "Relacja" "relation": "Relacja"
}, },
"storage-empty-header": "TRYB WYSZUKIWANA ZAPISANYCH ROZKŁADÓW JAZDY", "storage-empty-header": "TRYB WYSZUKIWANA ZAPISANYCH ROZKŁADÓW JAZDY",
"storage-empty-info": "Użyj funkcji zapisu rozkładu jazdy, aby go tutaj wyświetlić.", "storage-empty-info": "Użyj funkcji zapisu rozkładu jazdy, aby go tutaj wyświetlić.",
"storage-preview-title": "ZAPISANE ROZKŁADY JAZDY", "storage-preview-title": "ZAPISANE ROZKŁADY JAZDY",
+25 -19
View File
@@ -15,6 +15,8 @@ import type {
} from '../types/common.types'; } from '../types/common.types';
import { useGlobalStore } from './global.store'; import { useGlobalStore } from './global.store';
let activeDataInterval = -1;
export const useApiStore = defineStore('api', { export const useApiStore = defineStore('api', {
state() { state() {
return { return {
@@ -28,37 +30,41 @@ export const useApiStore = defineStore('api', {
isActiveDataOutdated: false, isActiveDataOutdated: false,
activeDataStatus: DataStatus.LOADING, activeDataStatus: DataStatus.LOADING,
journalDataStatus: DataStatus.SUCCESS journalDataStatus: DataStatus.SUCCESS,
connectionMode: 'online' as 'online' | 'offline'
}; };
}, },
actions: { actions: {
async setupAPIData() { async setupAPIData() {
if (this.client != null) return; if (this.client == null) {
let baseURL = 'https://stacjownik.spythere.eu';
let baseURL = 'https://stacjownik.spythere.eu'; switch (import.meta.env.VITE_API_MODE) {
case 'development':
baseURL = 'http://localhost:3001';
break;
case 'mocking':
baseURL = 'http://localhost:3123';
break;
default:
break;
}
switch (import.meta.env.VITE_API_MODE) { this.client = axios.create({
case 'development': baseURL
baseURL = 'http://localhost:3001'; });
break;
case 'mocking':
baseURL = 'http://localhost:3123';
break;
default:
break;
} }
this.client = axios.create({ clearInterval(activeDataInterval);
baseURL
}); activeDataInterval = setInterval(() => {
this.fetchActiveData();
}, 25000);
this.fetchSceneriesData(); this.fetchSceneriesData();
await this.fetchActiveData(); await this.fetchActiveData();
setInterval(() => {
this.fetchActiveData();
}, 25000);
}, },
async fetchActiveData() { async fetchActiveData() {
+12 -1
View File
@@ -32,7 +32,6 @@ body {
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
background: theme('colors.stone.900'); background: theme('colors.stone.900');
border-radius: 0 0 theme('borderRadius.md') 0; border-radius: 0 0 theme('borderRadius.md') 0;
} }
/* Tooltips */ /* Tooltips */
@@ -86,3 +85,15 @@ body {
color-scheme: light; color-scheme: light;
} }
} }
/* Animations */
.slide-anim-enter-active,
.slide-anim-leave-active {
transition: all 250ms ease-in-out;
transform: translateY(0);
}
.slide-anim-enter-from,
.slide-anim-leave-to {
transform: translateY(100%);
}
+2 -3
View File
@@ -1,14 +1,13 @@
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"types": ["vite/client", "vite-plugin-pwa/client"]
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
} }
+1
View File
@@ -0,0 +1 @@
{"root":["./src/i18n.ts","./src/main.ts","./src/vite-env.d.ts","./src/stores/api.store.ts","./src/stores/global.store.ts","./src/types/api.types.ts","./src/types/common.types.ts","./src/utils/trainUtils.ts","./src/App.vue","./src/components/App/MainBottom.vue","./src/components/App/MainContainer.vue","./src/components/App/Navbar.vue","./src/components/App/SettingsCard.vue","./src/components/App/UpdatePrompt.vue","./src/components/Timetable/TimetableBody.vue","./src/components/Timetable/TimetableHeader.vue","./src/components/Timetable/TimetableSelect.vue","./src/components/Timetable/TimetableWarnings.vue","./src/components/Timetable/TrainTimetable.vue","./src/components/TimetableViews/JournalStorageView.vue","./src/components/TimetableViews/LocalStorageView.vue"],"version":"5.6.3"}
+1 -3
View File
@@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022", "target": "ES2022",
"lib": ["ES2023"], "lib": ["ES2023"],
"module": "ESNext", "module": "ESNext",
@@ -17,8 +16,7 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true
"noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.3"}
+16 -4
View File
@@ -1,10 +1,22 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
vue(),
VitePWA({
registerType: 'prompt',
workbox: {
disableDevLogs: true,
globPatterns: ['**/*.{js,css,html,png,svg,jpg,ico}'],
cleanupOutdatedCaches: true
},
devOptions: { enabled: true }
})
],
server: { server: {
port: 5345 port: 5345
} }
}) });
+2478 -7
View File
File diff suppressed because it is too large Load Diff