feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

+26 -3
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
@@ -9,6 +10,7 @@ import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UpdatePill from '@/components/UpdatePill.vue'
import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults(
@@ -43,6 +45,7 @@ const bodyUi = computed(() => {
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const navItems = computed(() =>
@@ -52,7 +55,7 @@ const navItems = computed(() =>
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
@@ -154,7 +157,11 @@ async function onLogout() {
:collapsed="sidebarCollapsed"
aria-label="Main"
:ui="navUi"
/>
>
<template #settings-trailing>
<UpdatePill v-if="update.showCue && !sidebarCollapsed" />
</template>
</UNavigationMenu>
<template #footer>
<UserMenu
@@ -172,7 +179,6 @@ async function onLogout() {
<UDashboardPanel :ui="{ body: bodyUi }">
<template #header>
<UDashboardNavbar
:toggle="{ ui: { leadingIcon: 'size-5' } }"
:title="pageTitle"
:ui="{
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
@@ -181,6 +187,23 @@ async function onLogout() {
toggle: 'size-11 justify-center',
}"
>
<template #toggle>
<span class="relative lg:hidden">
<UDashboardSidebarToggle
data-slot="toggle"
color="neutral"
variant="ghost"
class="size-11 justify-center"
:ui="{ leadingIcon: 'size-5' }"
/>
<span
v-if="update.showCue"
role="img"
aria-label="Update available"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/>
</span>
</template>
<template #leading>
<UDashboardSidebarCollapse
color="neutral"
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
version: string
date: string
items: string[]
}>()
const open = ref(false)
</script>
<template>
<UCollapsible
v-model:open="open"
class="flex flex-col gap-2.5"
:ui="{ content: 'data-[state=closed]:hidden' }"
>
<UButton
color="neutral"
variant="link"
:icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8"
:ui="{ leadingIcon: 'size-3.5' }"
>
What's new in {{ version }}
</UButton>
<template #content>
<div class="flex flex-col gap-2 pl-5">
<span class="text-xs text-muted">{{ date }}</span>
<ul class="flex flex-col gap-2">
<li v-for="item in items" :key="item" class="flex gap-2">
<span aria-hidden="true" class="mt-2 size-1 shrink-0 bg-muted" />
<span class="text-sm text-pretty text-toned">{{ item }}</span>
</li>
</ul>
</div>
</template>
</UCollapsible>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const props = defineProps<{
line: string
}>()
const emit = defineEmits<{
copy: []
}>()
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
async function copyLine() {
try {
await copy(props.line)
} catch {
return
}
emit('copy')
}
</script>
<template>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-highlighted">For whoever manages the server</span>
<div class="flex flex-col gap-2 md:flex-row md:items-start">
<code
class="min-w-0 flex-1 truncate bg-elevated px-3 py-2 font-mono text-code/5.5 text-highlighted"
>{{ line }}</code
>
<UButton
color="neutral"
variant="outline"
:icon="buttonIcon"
aria-live="polite"
class="h-11 justify-center gap-1.5 border border-accented bg-default px-3 ring-0 md:h-8"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-4' }"
@click="copyLine"
>
{{ buttonLabel }}
</UButton>
</div>
<span class="text-xs/5 text-muted"
>They run this once on the server. After that, Update and Restart appear here.</span
>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
export type UpdateBannerState = 'available'
defineProps<{
state: UpdateBannerState
version: string
}>()
const emit = defineEmits<{
'go-to-settings': []
dismiss: []
}>()
</script>
<template>
<UAlert
role="region"
aria-label="Update available"
color="info"
variant="soft"
orientation="horizontal"
icon="i-lucide-circle-arrow-up"
:title="`Version ${version} is available`"
description="Update it from Settings. It takes about 2 minutes and the site stays online."
:ui="{
root: 'flex-wrap items-start gap-x-2.5 gap-y-3 border border-default py-3 ring-0 md:flex-nowrap md:items-center',
wrapper: 'basis-0 gap-0.5',
title: 'text-sm text-highlighted',
description: 'mt-0 text-sm/5 text-toned',
actions: 'max-md:w-full max-md:basis-full',
}"
>
<template #actions>
<div class="flex w-full items-center gap-2">
<UButton
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8 md:flex-none"
@click="emit('go-to-settings')"
>
Go to Settings
</UButton>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
aria-label="Dismiss"
class="size-11 shrink-0 justify-center text-muted md:size-8"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('dismiss')"
/>
</div>
</template>
</UAlert>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<UBadge label="Update" color="info" variant="solid" class="ml-auto h-4.5 px-1.5 py-0 text-xs" />
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
export type UpdateTone = 'neutral' | 'success' | 'info' | 'warning' | 'error'
const TONE_CLASSES: Record<UpdateTone, { block: string; icon: string }> = {
neutral: { block: 'bg-muted', icon: 'text-toned' },
success: { block: 'bg-(--rc-success-soft)', icon: 'text-success' },
info: { block: 'bg-(--rc-info-soft)', icon: 'text-info' },
warning: { block: 'bg-(--rc-warning-soft)', icon: 'text-warning' },
error: { block: 'bg-(--rc-error-soft)', icon: 'text-error' },
}
const props = defineProps<{
tone: UpdateTone
icon: string
title: string
text: string
meta?: string
}>()
const toneClasses = computed(() => TONE_CLASSES[props.tone])
const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
</script>
<template>
<div aria-live="polite" class="flex gap-3 p-3.5" :class="toneClasses.block">
<span class="flex pt-0.5">
<UIcon
:name="icon"
class="size-4.5 shrink-0"
:class="[
toneClasses.icon,
spinning ? 'animate-spin motion-reduce:animate-none' : '',
]"
/>
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-base font-semibold text-highlighted">{{ title }}</span>
<span class="text-sm text-pretty text-toned">{{ text }}</span>
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
</div>
</div>
</template>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed } from 'vue'
import ReleaseNotes from '@/components/ReleaseNotes.vue'
import ServerCommand from '@/components/ServerCommand.vue'
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
import type { UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import type { UpdateCheckPhase } from '@/stores/update'
import {
ENABLE_BROWSER_UPDATES_COMMAND,
displayVersion,
formatLastChecked,
formatReleased,
} from '@/lib/updateFormat'
interface Block {
tone: UpdateTone
icon: string
title: string
text: string
meta?: string
}
interface ActionButton {
label: string
icon: string
primary: boolean
disabled: boolean
}
const props = defineProps<{
status: UpdateStatus
role: UserRole | undefined
phase: UpdateCheckPhase
retryLabel: string
retryActive: boolean
}>()
const emit = defineEmits<{
check: []
}>()
const isOwner = computed(() => props.role === 'owner')
const newVersion = computed(() => displayVersion(props.status.latest))
const lastChecked = computed(() =>
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
)
const block = computed<Block>(() => {
if (isOwner.value && props.phase === 'checking') {
return {
tone: 'neutral',
icon: 'i-lucide-loader-circle',
title: 'Checking for updates…',
text: 'This usually takes a few seconds.',
}
}
if (isOwner.value && props.phase === 'limited') {
return {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
}
}
if (isOwner.value && props.phase === 'failed') {
return {
tone: 'warning',
icon: 'i-lucide-wifi-off',
title: "Couldn't check for updates",
text: "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
meta: lastChecked.value,
}
}
if (props.status.available && isOwner.value) {
return {
tone: 'info',
icon: 'i-lucide-info',
title: `Version ${newVersion.value} is available`,
text: "This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
}
}
if (props.status.available) {
return {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Only the owner can update RenewCMS. Ask the owner of this site.`,
}
}
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: "You're up to date",
text: isOwner.value
? 'You have the latest version of RenewCMS.'
: 'Only the owner can update or restart the site.',
meta: isOwner.value ? lastChecked.value : undefined,
}
})
const showServerGuidance = computed(
() => isOwner.value && props.status.available && props.phase === 'idle',
)
const actionButton = computed<ActionButton | null>(() => {
if (!isOwner.value) {
return null
}
if (props.phase === 'checking') {
return {
label: 'Checking…',
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
}
}
if (props.phase === 'failed') {
return { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: true, disabled: false }
}
if (props.phase === 'limited') {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
icon: 'i-lucide-clock',
primary: false,
disabled: true,
}
: { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: false, disabled: false }
}
if (props.status.available) {
return null
}
return {
label: 'Check for updates',
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
}
})
const notes = computed(() => {
const items = props.status.notes?.items ?? []
if (!showServerGuidance.value || items.length === 0 || !props.status.notes) {
return null
}
return {
items,
date: formatReleased(props.status.notes.released_at),
}
})
</script>
<template>
<section
aria-labelledby="updates-h"
data-testid="updates-card"
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
>
<h2
id="updates-h"
tabindex="-1"
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
>
Updates
</h2>
<UpdateStatusBlock
:tone="block.tone"
:icon="block.icon"
:title="block.title"
:text="block.text"
:meta="block.meta"
/>
<ReleaseNotes v-if="notes" :version="newVersion" :date="notes.date" :items="notes.items" />
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
<div v-if="actionButton" class="flex flex-col gap-2 md:flex-row md:items-center">
<UButton
:color="actionButton.primary ? 'primary' : 'neutral'"
:variant="actionButton.primary ? 'solid' : 'outline'"
:icon="actionButton.icon"
:disabled="actionButton.disabled"
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
:class="
actionButton.primary
? ''
: 'border border-accented bg-default text-highlighted ring-0'
"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('check')"
>
{{ actionButton.label }}
</UButton>
</div>
<a
v-if="showServerGuidance && status.release_url"
:href="status.release_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
How to update
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
</section>
</template>