diff --git a/fe/quasar.config.ts b/fe/quasar.config.ts index a59d2da..7a12b60 100644 --- a/fe/quasar.config.ts +++ b/fe/quasar.config.ts @@ -162,12 +162,12 @@ export default defineConfig((ctx) => { // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa pwa: { - workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest' - // swFilename: 'sw.js', - // manifestFilename: 'manifest.json', + workboxMode: 'InjectManifest', // Changed from 'GenerateSW' to 'InjectManifest' + swFilename: 'sw.js', + manifestFilename: 'manifest.json', + injectPwaMetaTags: true, // extendManifestJson (json) {}, // useCredentialsForManifestTag: true, - // injectPwaMetaTags: false, // extendPWACustomSWConf (esbuildConf) {}, // extendGenerateSWOptions (cfg) {}, // extendInjectManifestOptions (cfg) {} diff --git a/fe/src-pwa/custom-service-worker.ts b/fe/src-pwa/custom-service-worker.ts index 3201f3a..883c1cd 100644 --- a/fe/src-pwa/custom-service-worker.ts +++ b/fe/src-pwa/custom-service-worker.ts @@ -14,6 +14,10 @@ import { createHandlerBoundToURL, } from 'workbox-precaching'; 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) => { console.error('Error during service worker activation:', error); @@ -25,6 +29,46 @@ precacheAndRoute(self.__WB_MANIFEST); 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 // Production SSR fallbacks to offline.html (except for dev) if (process.env.MODE !== 'ssr' || process.env.PROD) { diff --git a/fe/src/components/ConflictResolutionDialog.vue b/fe/src/components/ConflictResolutionDialog.vue new file mode 100644 index 0000000..7733cfa --- /dev/null +++ b/fe/src/components/ConflictResolutionDialog.vue @@ -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> \ No newline at end of file diff --git a/fe/src/components/OfflineIndicator.vue b/fe/src/components/OfflineIndicator.vue new file mode 100644 index 0000000..43ad775 --- /dev/null +++ b/fe/src/components/OfflineIndicator.vue @@ -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> \ No newline at end of file diff --git a/fe/src/layouts/MainLayout.vue b/fe/src/layouts/MainLayout.vue index 29c5e76..8bbbbee 100644 --- a/fe/src/layouts/MainLayout.vue +++ b/fe/src/layouts/MainLayout.vue @@ -32,6 +32,9 @@ <router-view /> </q-page-container> + <!-- Offline Indicator --> + <OfflineIndicator /> + <!-- Bottom Navigation --> <q-footer elevated class="bg-white text-primary"> <q-tabs @@ -55,6 +58,7 @@ import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useQuasar } from 'quasar'; import { useAuthStore } from 'stores/auth'; +import OfflineIndicator from 'components/OfflineIndicator.vue'; const router = useRouter(); const $q = useQuasar(); diff --git a/fe/src/stores/offline.ts b/fe/src/stores/offline.ts new file mode 100644 index 0000000..84533f5 --- /dev/null +++ b/fe/src/stores/offline.ts @@ -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, + }; +}); \ No newline at end of file