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:
parent
7bbec7ad5f
commit
db5f2d089e
@ -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) {}
|
||||||
|
@ -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) {
|
||||||
|
288
fe/src/components/ConflictResolutionDialog.vue
Normal file
288
fe/src/components/ConflictResolutionDialog.vue
Normal 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>
|
123
fe/src/components/OfflineIndicator.vue
Normal file
123
fe/src/components/OfflineIndicator.vue
Normal 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>
|
@ -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
157
fe/src/stores/offline.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user