Files
thetool/public/mobile/components/OfflineIndicator.js
2026-01-27 10:35:15 +01:00

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>
`
};