Files
remanso/src/components/NoteFreshnessBadge.vue
Julien Calixte d31c774ace feat(stacked-note): surface note freshness and guard saves on conflict
Adds a Tabler-icon badge in the stacked-note action bar showing whether
the loaded copy still matches GitHub HEAD (verified / outdated / offline
/ checking / unknown / stale-known). The save flow now re-checks before
the PUT and opens a conflict modal when GitHub has moved on, with three
explicit choices: discard local edits and pull, overwrite anyway, or
cancel. Race-condition 409s from the PUT itself are routed through the
same modal.
2026-05-03 23:32:54 +02:00

238 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { computed } from "vue"
import type { FreshnessStatus } from "@/hooks/useNoteFreshness.hook"
const props = defineProps<{
status: FreshnessStatus
lastCheckedAt: Date | null
}>()
defineEmits<{ (e: "click"): void }>()
const formatTime = (d: Date) =>
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
const minutesAgo = (d: Date) =>
Math.max(1, Math.round((Date.now() - d.getTime()) / 60000))
const label = computed(() => {
switch (props.status) {
case "verified":
return "Up to date"
case "checking":
return "Checking…"
case "outdated":
return "Outdated"
case "stale-known":
return props.lastCheckedAt
? `Checked ${minutesAgo(props.lastCheckedAt)}m ago`
: "Not checked"
case "offline":
return "Cant reach GitHub"
case "unknown":
default:
return "Not checked"
}
})
const tooltip = computed(() => {
switch (props.status) {
case "verified":
return props.lastCheckedAt
? `Verified at ${formatTime(props.lastCheckedAt)}. Click to re-check.`
: "Click to re-check."
case "outdated":
return "GitHub has a newer version. Click to pull latest."
case "stale-known":
return "Click to verify against GitHub."
case "offline":
return "Could not reach GitHub. Click to retry."
case "checking":
return "Checking against GitHub…"
case "unknown":
default:
return "Click to check against GitHub."
}
})
const stateClass = computed(() => `state-${props.status}`)
const isBusy = computed(() => props.status === "checking")
</script>
<template>
<button
class="freshness button is-text is-light"
:class="stateClass"
:title="tooltip"
:aria-label="tooltip"
:disabled="isBusy"
@click="$emit('click')"
>
<svg
v-if="status === 'verified'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-check"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.388 0 2.585 .82 3.138 2.007"
/>
<path d="M15 19l2 2l4 -4" />
</svg>
<svg
v-else-if="status === 'unknown' || status === 'stale-known'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler"
:class="
status === 'stale-known'
? 'icon-tabler-clock'
: 'icon-tabler-cloud-question'
"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<template v-if="status === 'stale-known'">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M12 7v5l3 3" />
</template>
<template v-else>
<path
d="M14.5 18.004h-7.843c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99"
/>
<path d="M19 22v.01" />
<path
d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"
/>
</template>
</svg>
<svg
v-else-if="status === 'outdated'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-download"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4"
/>
<path d="M12 13l0 9" />
<path d="M9 19l3 3l3 -3" />
</svg>
<svg
v-else-if="status === 'checking'"
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-loader-2 spin"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-cloud-off"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"
/>
<path d="M3 3l18 18" />
</svg>
<span class="freshness-label">{{ label }}</span>
</button>
</template>
<style lang="scss" scoped>
.freshness {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
background: transparent;
border: 0;
cursor: pointer;
vertical-align: middle;
&[disabled] {
cursor: progress;
}
.icon {
flex-shrink: 0;
}
.freshness-label {
line-height: 1;
}
}
.state-verified {
color: var(--color-success, hsl(140, 60%, 35%));
}
.state-outdated {
color: var(--color-warning, hsl(35, 90%, 45%));
}
.state-offline {
color: var(--color-error, hsl(0, 70%, 45%));
}
.state-unknown,
.state-stale-known,
.state-checking {
color: var(--color-base-content);
opacity: 0.6;
}
.spin {
animation: freshness-spin 1s linear infinite;
}
@keyframes freshness-spin {
to {
transform: rotate(360deg);
}
}
@media screen and (max-width: 768px) {
.freshness-label {
display: none;
}
}
</style>