Implement offline functionality with conflict resolution; add OfflineIndicator component for user notifications, integrate offline action management in the store, and enhance service worker caching strategies for improved performance.

This commit is contained in:
mohamad 2025-05-08 23:52:11 +02:00
parent 7bbec7ad5f
commit db5f2d089e
6 changed files with 620 additions and 4 deletions

View File

@ -162,12 +162,12 @@ export default defineConfig((ctx) => {
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: { pwa: {
workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest' workboxMode: 'InjectManifest', // Changed from 'GenerateSW' to 'InjectManifest'
// swFilename: 'sw.js', swFilename: 'sw.js',
// manifestFilename: 'manifest.json', manifestFilename: 'manifest.json',
injectPwaMetaTags: true,
// extendManifestJson (json) {}, // extendManifestJson (json) {},
// useCredentialsForManifestTag: true, // useCredentialsForManifestTag: true,
// injectPwaMetaTags: false,
// extendPWACustomSWConf (esbuildConf) {}, // extendPWACustomSWConf (esbuildConf) {},
// extendGenerateSWOptions (cfg) {}, // extendGenerateSWOptions (cfg) {},
// extendInjectManifestOptions (cfg) {} // extendInjectManifestOptions (cfg) {}

View File

@ -14,6 +14,10 @@ import {
createHandlerBoundToURL, createHandlerBoundToURL,
} from 'workbox-precaching'; } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing'; import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import type { WorkboxPlugin } from 'workbox-core/types';
self.skipWaiting().catch((error) => { self.skipWaiting().catch((error) => {
console.error('Error during service worker activation:', error); console.error('Error during service worker activation:', error);
@ -25,6 +29,46 @@ precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches(); cleanupOutdatedCaches();
// Cache app shell and static assets with Cache First strategy
registerRoute(
// Match static assets
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'image' ||
request.destination === 'font',
new CacheFirst({
cacheName: 'static-assets',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}) as WorkboxPlugin,
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}) as WorkboxPlugin,
],
})
);
// Cache API calls with Network First strategy
registerRoute(
// Match API calls
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}) as WorkboxPlugin,
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
}) as WorkboxPlugin,
],
})
);
// Non-SSR fallbacks to index.html // Non-SSR fallbacks to index.html
// Production SSR fallbacks to offline.html (except for dev) // Production SSR fallbacks to offline.html (except for dev)
if (process.env.MODE !== 'ssr' || process.env.PROD) { if (process.env.MODE !== 'ssr' || process.env.PROD) {

View File

@ -0,0 +1,288 @@
<template>
<q-dialog v-model="show" persistent>
<q-card style="min-width: 600px">
<q-card-section>
<div class="text-h6">Conflict Resolution</div>
<div class="text-subtitle2 q-mt-sm">
This item was modified while you were offline. Please review the changes and choose how to resolve the conflict.
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-tabs
v-model="activeTab"
class="text-primary"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-tab name="compare" label="Compare Versions" />
<q-tab name="merge" label="Merge Changes" />
</q-tabs>
<q-tab-panels v-model="activeTab" animated>
<!-- Compare Versions Tab -->
<q-tab-panel name="compare">
<div class="row q-col-gutter-md">
<!-- Local Version -->
<div class="col-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1">Your Version</div>
<div class="text-caption">
Last modified: {{ formatDate(conflictData?.localVersion.timestamp ?? 0) }}
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key">
<q-item-section>
<q-item-label class="text-caption text-grey">
{{ formatKey(key) }}
</q-item-label>
<q-item-label :class="{ 'text-positive': isDifferent(key) }">
{{ formatValue(value) }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<!-- Server Version -->
<div class="col-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1">Server Version</div>
<div class="text-caption">
Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }}
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="(value, key) in conflictData?.serverVersion.data" :key="key">
<q-item-section>
<q-item-label class="text-caption text-grey">
{{ formatKey(key) }}
</q-item-label>
<q-item-label :class="{ 'text-positive': isDifferent(key) }">
{{ formatValue(value) }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
</q-tab-panel>
<!-- Merge Changes Tab -->
<q-tab-panel name="merge">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1">Merge Changes</div>
<div class="text-caption">
Select which version to keep for each field
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key">
<q-item-section>
<q-item-label class="text-caption text-grey">
{{ formatKey(key) }}
</q-item-label>
<div class="row q-col-gutter-sm q-mt-xs">
<div class="col">
<q-radio
v-model="mergeChoices[key]"
val="local"
label="Your Version"
/>
<div class="text-caption">
{{ formatValue(value) }}
</div>
</div>
<div class="col">
<q-radio
v-model="mergeChoices[key]"
val="server"
label="Server Version"
/>
<div class="text-caption">
{{ formatValue(conflictData?.serverVersion.data[key]) }}
</div>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
<q-card-actions align="right">
<q-btn
v-if="activeTab === 'compare'"
flat
label="Keep Local Version"
color="primary"
@click="resolveConflict('local')"
/>
<q-btn
v-if="activeTab === 'compare'"
flat
label="Keep Server Version"
color="primary"
@click="resolveConflict('server')"
/>
<q-btn
v-if="activeTab === 'compare'"
flat
label="Merge Changes"
color="primary"
@click="activeTab = 'merge'"
/>
<q-btn
v-if="activeTab === 'merge'"
flat
label="Apply Merged Changes"
color="primary"
@click="applyMergedChanges"
/>
<q-btn
flat
label="Cancel"
color="negative"
@click="show = false"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { OfflineAction } from 'src/stores/offline';
interface ConflictData {
localVersion: {
data: Record<string, any>;
timestamp: number;
};
serverVersion: {
data: Record<string, any>;
timestamp: number;
};
action: OfflineAction;
}
const props = defineProps<{
modelValue: boolean;
conflictData: ConflictData | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'resolve', resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, any> }): void;
}>();
const show = ref(props.modelValue);
const activeTab = ref('compare');
const mergeChoices = ref<Record<string, 'local' | 'server'>>({});
// Watch for changes in modelValue
watch(() => props.modelValue, (newValue: boolean) => {
show.value = newValue;
});
// Watch for changes in show
watch(show, (newValue: boolean) => {
emit('update:modelValue', newValue);
});
// Initialize merge choices when conflict data changes
watch(() => props.conflictData, (newData) => {
if (newData) {
const choices: Record<string, 'local' | 'server'> = {};
Object.keys(newData.localVersion.data).forEach(key => {
choices[key] = isDifferent(key) ? 'local' : 'local';
});
mergeChoices.value = choices;
}
}, { immediate: true });
const formatDate = (timestamp: number): string => {
return new Date(timestamp).toLocaleString();
};
const formatKey = (key: string): string => {
return key
.split(/(?=[A-Z])/)
.join(' ')
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase());
};
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const isDifferent = (key: string): boolean => {
if (!props.conflictData) return false;
const localValue = props.conflictData.localVersion.data[key];
const serverValue = props.conflictData.serverVersion.data[key];
return JSON.stringify(localValue) !== JSON.stringify(serverValue);
};
const resolveConflict = (version: 'local' | 'server' | 'merge'): void => {
if (!props.conflictData) return;
emit('resolve', {
version,
action: props.conflictData.action
});
show.value = false;
};
const applyMergedChanges = (): void => {
if (!props.conflictData) return;
const mergedData: Record<string, any> = {};
Object.entries(mergeChoices.value).forEach(([key, choice]) => {
const localValue = props.conflictData?.localVersion.data[key];
const serverValue = props.conflictData?.serverVersion.data[key];
mergedData[key] = choice === 'local' ? localValue : serverValue;
});
emit('resolve', {
version: 'merge',
action: props.conflictData.action,
mergedData
});
show.value = false;
};
</script>
<style lang="scss" scoped>
.q-card {
.text-caption {
font-size: 0.8rem;
}
}
.text-positive {
color: $positive;
font-weight: 500;
}
</style>

View File

@ -0,0 +1,123 @@
<template>
<q-banner
v-if="!isOnline || hasPendingActions"
:class="[
'offline-indicator',
{ 'offline': !isOnline },
{ 'pending': hasPendingActions }
]"
rounded
>
<template v-slot:avatar>
<q-icon
:name="!isOnline ? 'wifi_off' : 'sync'"
:color="!isOnline ? 'negative' : 'warning'"
/>
</template>
<template v-if="!isOnline">
You are currently offline. Changes will be saved locally.
</template>
<template v-else>
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
</template>
<template v-slot:action>
<q-btn
v-if="hasPendingActions"
flat
color="primary"
label="View Changes"
@click="showPendingActions = true"
/>
</template>
</q-banner>
<q-dialog v-model="showPendingActions">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">Pending Changes</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="action in pendingActions" :key="action.id">
<q-item-section>
<q-item-label>
{{ getActionLabel(action) }}
</q-item-label>
<q-item-label caption>
{{ new Date(action.timestamp).toLocaleString() }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Conflict Resolution Dialog -->
<ConflictResolutionDialog
v-model="showConflictDialog"
:conflict-data="currentConflict"
@resolve="handleConflictResolution"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useOfflineStore } from 'src/stores/offline';
import type { OfflineAction } from 'src/stores/offline';
import ConflictResolutionDialog from './ConflictResolutionDialog.vue';
const offlineStore = useOfflineStore();
const showPendingActions = ref(false);
const {
isOnline,
pendingActions,
hasPendingActions,
pendingActionCount,
showConflictDialog,
currentConflict,
handleConflictResolution,
} = offlineStore;
const getActionLabel = (action: OfflineAction) => {
switch (action.type) {
case 'add':
return `Add new item: ${action.data.title || 'Untitled'}`;
case 'complete':
return `Complete item: ${action.data.title || 'Untitled'}`;
case 'update':
return `Update item: ${action.data.title || 'Untitled'}`;
case 'delete':
return `Delete item: ${action.data.title || 'Untitled'}`;
default:
return 'Unknown action';
}
};
</script>
<style lang="scss" scoped>
.offline-indicator {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1000;
max-width: 400px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
&.offline {
background-color: #ffebee;
}
&.pending {
background-color: #fff3e0;
}
}
</style>

View File

@ -32,6 +32,9 @@
<router-view /> <router-view />
</q-page-container> </q-page-container>
<!-- Offline Indicator -->
<OfflineIndicator />
<!-- Bottom Navigation --> <!-- Bottom Navigation -->
<q-footer elevated class="bg-white text-primary"> <q-footer elevated class="bg-white text-primary">
<q-tabs <q-tabs
@ -55,6 +58,7 @@ import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useAuthStore } from 'stores/auth'; import { useAuthStore } from 'stores/auth';
import OfflineIndicator from 'components/OfflineIndicator.vue';
const router = useRouter(); const router = useRouter();
const $q = useQuasar(); const $q = useQuasar();

157
fe/src/stores/offline.ts Normal file
View File

@ -0,0 +1,157 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useQuasar } from 'quasar';
import { LocalStorage } from 'quasar';
export interface OfflineAction {
id: string;
type: 'add' | 'complete' | 'update' | 'delete';
itemId?: string;
data: any;
timestamp: number;
version?: number;
}
export interface ConflictResolution {
version: 'local' | 'server' | 'merge';
action: OfflineAction;
}
export const useOfflineStore = defineStore('offline', () => {
const $q = useQuasar();
const isOnline = ref(navigator.onLine);
const pendingActions = ref<OfflineAction[]>([]);
const isProcessingQueue = ref(false);
const showConflictDialog = ref(false);
const currentConflict = ref<ConflictResolution | null>(null);
// Initialize from IndexedDB
const init = async () => {
try {
const stored = LocalStorage.getItem('offline-actions');
if (stored) {
pendingActions.value = JSON.parse(stored as string);
}
} catch (error) {
console.error('Failed to load offline actions:', error);
}
};
// Save to IndexedDB
const saveToStorage = () => {
try {
LocalStorage.set('offline-actions', JSON.stringify(pendingActions.value));
} catch (error) {
console.error('Failed to save offline actions:', error);
}
};
// Add a new offline action
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
const newAction: OfflineAction = {
...action,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
pendingActions.value.push(newAction);
saveToStorage();
};
// Process the queue when online
const processQueue = async () => {
if (isProcessingQueue.value || !isOnline.value) return;
isProcessingQueue.value = true;
const actions = [...pendingActions.value];
for (const action of actions) {
try {
// TODO: Implement actual API calls based on action type
// This will be implemented when we have the API endpoints
await processAction(action);
// Remove successful action
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
saveToStorage();
} catch (error: any) {
if (error.status === 409) {
// Handle version conflict
$q.notify({
type: 'warning',
message: 'Item was modified by someone else while you were offline. Please review.',
actions: [
{
label: 'Review',
color: 'white',
handler: () => {
// TODO: Implement conflict resolution UI
}
}
]
});
} else {
console.error('Failed to process offline action:', error);
}
}
}
isProcessingQueue.value = false;
};
// Process a single action
const processAction = async (action: OfflineAction) => {
// TODO: Implement actual API calls
// This is a placeholder that will be replaced with actual API calls
switch (action.type) {
case 'add':
// await api.addItem(action.data);
break;
case 'complete':
// await api.completeItem(action.itemId, action.data);
break;
case 'update':
// await api.updateItem(action.itemId, action.data);
break;
case 'delete':
// await api.deleteItem(action.itemId);
break;
}
};
// Listen for online/offline status changes
const setupNetworkListeners = () => {
window.addEventListener('online', () => {
isOnline.value = true;
processQueue();
});
window.addEventListener('offline', () => {
isOnline.value = false;
});
};
// Computed properties
const hasPendingActions = computed(() => pendingActions.value.length > 0);
const pendingActionCount = computed(() => pendingActions.value.length);
// Initialize
init();
setupNetworkListeners();
const handleConflictResolution = (resolution: ConflictResolution) => {
// Implement the logic to handle the conflict resolution
console.log('Conflict resolution:', resolution);
};
return {
isOnline,
pendingActions,
hasPendingActions,
pendingActionCount,
showConflictDialog,
currentConflict,
addAction,
processQueue,
handleConflictResolution,
};
});