212 lines
7.5 KiB
JavaScript
212 lines
7.5 KiB
JavaScript
/**
|
|
* Offline Indicator Component
|
|
*
|
|
* Displays network status and sync state in the header.
|
|
* Shows: Online, Offline, Syncing states with appropriate colors.
|
|
*/
|
|
|
|
const { ref, computed, onMounted, onUnmounted, watch } = Vue;
|
|
|
|
export default {
|
|
name: 'OfflineIndicator',
|
|
|
|
props: {
|
|
// Whether offline mode is enabled in settings
|
|
offlineModeEnabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// Number of pending changes
|
|
pendingCount: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
// Whether sync is currently running
|
|
isSyncing: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// Data freshness level: 'fresh', 'stale', 'old', 'unknown'
|
|
freshness: {
|
|
type: String,
|
|
default: 'unknown'
|
|
}
|
|
},
|
|
|
|
emits: ['sync-click'],
|
|
|
|
setup(props, { emit }) {
|
|
const isOnline = ref(navigator.onLine);
|
|
|
|
// Update online status
|
|
const updateOnlineStatus = () => {
|
|
isOnline.value = navigator.onLine;
|
|
};
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('online', updateOnlineStatus);
|
|
window.addEventListener('offline', updateOnlineStatus);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('online', updateOnlineStatus);
|
|
window.removeEventListener('offline', updateOnlineStatus);
|
|
});
|
|
|
|
// Computed display state
|
|
const displayState = computed(() => {
|
|
if (!props.offlineModeEnabled) {
|
|
return isOnline.value ? 'online' : 'offline-no-cache';
|
|
}
|
|
|
|
if (props.isSyncing) {
|
|
return 'syncing';
|
|
}
|
|
|
|
if (!isOnline.value) {
|
|
return 'offline';
|
|
}
|
|
|
|
if (props.pendingCount > 0) {
|
|
return 'pending';
|
|
}
|
|
|
|
return 'online';
|
|
});
|
|
|
|
// Status text
|
|
const statusText = computed(() => {
|
|
switch (displayState.value) {
|
|
case 'syncing':
|
|
return 'Synchronisiere...';
|
|
case 'offline':
|
|
return 'Offline';
|
|
case 'offline-no-cache':
|
|
return 'Keine Verbindung';
|
|
case 'pending':
|
|
return `${props.pendingCount} ausstehend`;
|
|
case 'online':
|
|
return props.offlineModeEnabled ? 'Synchronisiert' : '';
|
|
default:
|
|
return '';
|
|
}
|
|
});
|
|
|
|
// Status icon
|
|
const statusIcon = computed(() => {
|
|
switch (displayState.value) {
|
|
case 'syncing':
|
|
return 'sync';
|
|
case 'offline':
|
|
case 'offline-no-cache':
|
|
return 'cloud-off';
|
|
case 'pending':
|
|
return 'cloud-upload';
|
|
case 'online':
|
|
return 'cloud-check';
|
|
default:
|
|
return 'cloud';
|
|
}
|
|
});
|
|
|
|
// Status color classes
|
|
const statusClasses = computed(() => {
|
|
switch (displayState.value) {
|
|
case 'syncing':
|
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300';
|
|
case 'offline':
|
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300';
|
|
case 'offline-no-cache':
|
|
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300';
|
|
case 'pending':
|
|
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300';
|
|
case 'online':
|
|
return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300';
|
|
default:
|
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
|
}
|
|
});
|
|
|
|
// Freshness indicator color
|
|
const freshnessColor = computed(() => {
|
|
switch (props.freshness) {
|
|
case 'fresh':
|
|
return 'bg-green-500';
|
|
case 'stale':
|
|
return 'bg-yellow-500';
|
|
case 'old':
|
|
return 'bg-red-500';
|
|
default:
|
|
return 'bg-gray-400';
|
|
}
|
|
});
|
|
|
|
// Click handler
|
|
const handleClick = () => {
|
|
if (isOnline.value && props.pendingCount > 0) {
|
|
emit('sync-click');
|
|
}
|
|
};
|
|
|
|
// Should show (only show if offline mode enabled or offline)
|
|
const shouldShow = computed(() => {
|
|
return props.offlineModeEnabled || !isOnline.value;
|
|
});
|
|
|
|
return {
|
|
isOnline,
|
|
displayState,
|
|
statusText,
|
|
statusIcon,
|
|
statusClasses,
|
|
freshnessColor,
|
|
handleClick,
|
|
shouldShow
|
|
};
|
|
},
|
|
|
|
template: `
|
|
<div
|
|
v-if="shouldShow"
|
|
:class="[
|
|
'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium transition-all',
|
|
statusClasses,
|
|
(isOnline && pendingCount > 0) ? 'cursor-pointer hover:opacity-80' : ''
|
|
]"
|
|
@click="handleClick"
|
|
>
|
|
<!-- Syncing spinner -->
|
|
<svg v-if="displayState === 'syncing'" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
|
|
<!-- Cloud off icon (offline) -->
|
|
<svg v-else-if="displayState === 'offline' || displayState === 'offline-no-cache'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3l18 18M10.5 6.5A7.5 7.5 0 0118 12m-3.5 4.5H6a4 4 0 01-.5-7.97"/>
|
|
</svg>
|
|
|
|
<!-- Cloud upload icon (pending) -->
|
|
<svg v-else-if="displayState === 'pending'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
|
</svg>
|
|
|
|
<!-- Cloud check icon (synced) -->
|
|
<svg v-else-if="displayState === 'online'" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"/>
|
|
</svg>
|
|
|
|
<!-- Status text -->
|
|
<span v-if="statusText">{{ statusText }}</span>
|
|
|
|
<!-- Freshness dot (when showing cached data) -->
|
|
<span
|
|
v-if="offlineModeEnabled && displayState !== 'syncing'"
|
|
:class="['w-1.5 h-1.5 rounded-full', freshnessColor]"
|
|
:title="freshness === 'fresh' ? 'Aktuell' : freshness === 'stale' ? 'Etwas älter' : freshness === 'old' ? 'Veraltet' : ''"
|
|
></span>
|
|
</div>
|
|
`
|
|
};
|