104 lines
2.6 KiB
Vue
104 lines
2.6 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
const props = defineProps<{
|
|
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
|
|
savedAt?: Date | null
|
|
}>()
|
|
|
|
defineEmits<{ retry: [] }>()
|
|
|
|
const MINUTE_MS = 60 * 1000
|
|
const HOUR_MS = 60 * MINUTE_MS
|
|
const TICK_MS = 15000
|
|
|
|
const now = ref(Date.now())
|
|
let ticker: ReturnType<typeof setInterval> | null = null
|
|
|
|
onMounted(() => {
|
|
ticker = setInterval(() => {
|
|
now.value = Date.now()
|
|
}, TICK_MS)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (ticker !== null) {
|
|
clearInterval(ticker)
|
|
}
|
|
})
|
|
|
|
function formatClock(date: Date) {
|
|
const hours = date.getHours().toString().padStart(2, '0')
|
|
const minutes = date.getMinutes().toString().padStart(2, '0')
|
|
return `${hours}:${minutes}`
|
|
}
|
|
|
|
function formatElapsed(diffMs: number) {
|
|
const minutes = Math.floor(diffMs / MINUTE_MS)
|
|
if (minutes < 60) {
|
|
return t('{0} min', [minutes])
|
|
}
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) {
|
|
return t('{0} h', [hours])
|
|
}
|
|
return t('{0} d', [Math.floor(hours / 24)])
|
|
}
|
|
|
|
const savedText = computed(() => {
|
|
if (!props.savedAt) {
|
|
return t('Saved')
|
|
}
|
|
const diffMs = now.value - props.savedAt.getTime()
|
|
if (diffMs < HOUR_MS) {
|
|
return t('Saved at {0}', [formatClock(props.savedAt)])
|
|
}
|
|
return t('Saved {0} ago', [formatElapsed(diffMs)])
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<span
|
|
aria-live="polite"
|
|
class="inline-flex shrink-0 items-center gap-1.5 text-sm whitespace-nowrap"
|
|
:class="{
|
|
'text-warning': state === 'dirty',
|
|
'text-error': state === 'error',
|
|
'text-muted': state !== 'dirty' && state !== 'error',
|
|
}"
|
|
>
|
|
<template v-if="state === 'clean' || state === 'saved'">
|
|
<UIcon name="i-lucide-check" class="size-3.5" />
|
|
<span>{{ savedText }}</span>
|
|
</template>
|
|
<template v-else-if="state === 'dirty'">
|
|
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
|
|
<span>{{ t('Unsaved changes') }}</span>
|
|
</template>
|
|
<template v-else-if="state === 'saving'">
|
|
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
|
<span>{{ t('Saving…') }}</span>
|
|
</template>
|
|
<template v-else-if="state === 'error'">
|
|
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
|
|
<span>{{ t('Save failed') }}</span>
|
|
<UButton
|
|
size="xs"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-rotate-cw"
|
|
class="h-6 gap-1 px-2 text-xs text-highlighted"
|
|
@click="$emit('retry')"
|
|
>
|
|
{{ t('Retry') }}
|
|
</UButton>
|
|
</template>
|
|
<template v-else-if="state === 'offline'">
|
|
<UIcon name="i-lucide-wifi-off" class="size-3.5" />
|
|
<span>{{ t('Waiting for connection') }}</span>
|
|
</template>
|
|
</span>
|
|
</template>
|