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
+52 -13
View File
@@ -1,5 +1,11 @@
<template>
<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 />
<MainContainer />
</div>
@@ -8,10 +14,14 @@
<script lang="ts" setup>
import Navbar from './components/App/Navbar.vue';
import MainContainer from './components/App/MainContainer.vue';
import UpdatePrompt from './components/App/UpdatePrompt.vue';
import { onMounted } from 'vue';
import { useApiStore } from './stores/api.store';
import { useGlobalStore } from './stores/global.store';
import { useI18n } from 'vue-i18n';
import { useRegisterSW } from 'virtual:pwa-register/vue';
import { DataStatus } from './types/api.types';
const originalDocumentTitle = document.title;
@@ -19,28 +29,24 @@ const apiStore = useApiStore();
const globalStore = useGlobalStore();
const i18n = useI18n();
const { needRefresh, updateServiceWorker } = useRegisterSW({ immediate: true });
onMounted(async () => {
setupLocale();
setupDarkMode();
setupOfflineMode();
loadStorageTimetables();
setupAfterPrintClose();
await apiStore.setupAPIData();
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;
}
}
handleQueries();
});
function updateApp() {
updateServiceWorker(true);
needRefresh.value = false;
}
function loadStorageTimetables() {
if (!window.localStorage.getItem('savedTimetables')) return;
@@ -73,4 +79,37 @@ function setupLocale() {
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>
+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
type="text"
v-else-if="globalStore.viewMode == 'journal'"
v-model="globalStore.journalTimetableSearch"
@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')"
/>
</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 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>
<LocalStorageView v-else-if="globalStore.viewMode == 'storage'" />
@@ -4,7 +4,11 @@
{{ $t('journal-preview-title') }}
</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') }}
</div>
+9
View File
@@ -4,6 +4,14 @@
"train-select-placeholder": "Choose active train from the list",
"train-select-info": "Choose active train to generate SRJP timetable",
"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": {
"line_no": "Line\nno.",
"line_km": "Km",
@@ -17,6 +25,7 @@
"vmax": "Vmax",
"relation": "Route"
},
"storage-empty-header": "ARCHIVED TIMETABLES SEARCH MODE",
"storage-empty-info": "Timetables will be shown here after their archiving.",
"storage-preview-title": "ARCHIVED TIMETABLES",
+9
View File
@@ -4,6 +4,14 @@
"train-select-placeholder": "Wybierz pociąg z listy",
"train-select-info": "Wybierz aktywny pociąg, aby wygenerować SRJP",
"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": {
"line_no": "Nr\nlinii",
"line_km": "Km",
@@ -17,6 +25,7 @@
"vmax": "Vmax",
"relation": "Relacja"
},
"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-preview-title": "ZAPISANE ROZKŁADY JAZDY",
+25 -19
View File
@@ -15,6 +15,8 @@ import type {
} from '../types/common.types';
import { useGlobalStore } from './global.store';
let activeDataInterval = -1;
export const useApiStore = defineStore('api', {
state() {
return {
@@ -28,37 +30,41 @@ export const useApiStore = defineStore('api', {
isActiveDataOutdated: false,
activeDataStatus: DataStatus.LOADING,
journalDataStatus: DataStatus.SUCCESS
journalDataStatus: DataStatus.SUCCESS,
connectionMode: 'online' as 'online' | 'offline'
};
},
actions: {
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) {
case 'development':
baseURL = 'http://localhost:3001';
break;
case 'mocking':
baseURL = 'http://localhost:3123';
break;
default:
break;
this.client = axios.create({
baseURL
});
}
this.client = axios.create({
baseURL
});
clearInterval(activeDataInterval);
activeDataInterval = setInterval(() => {
this.fetchActiveData();
}, 25000);
this.fetchSceneriesData();
await this.fetchActiveData();
setInterval(() => {
this.fetchActiveData();
}, 25000);
},
async fetchActiveData() {
+12 -1
View File
@@ -32,7 +32,6 @@ body {
::-webkit-scrollbar-corner {
background: theme('colors.stone.900');
border-radius: 0 0 theme('borderRadius.md') 0;
}
/* Tooltips */
@@ -86,3 +85,15 @@ body {
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%);
}