feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4191
-18
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user