Merge pull request 'Always display invites status and new API information' (#18) from dev into main
This commit was merged in pull request #18.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { getInvitesApi, createInviteApi, deleteInviteApi } from '../invites'
|
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '../invites'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { API_BASE_URL } from '@/api/http'
|
import { API_BASE_URL } from '@/api/http'
|
||||||
|
|
||||||
@@ -65,6 +65,33 @@ describe('invites API', () => {
|
|||||||
await expect(getInvitesApi()).rejects.toThrow('Failed')
|
await expect(getInvitesApi()).rejects.toThrow('Failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('getInviteStatusApi sends GET to /user/invites/status and returns the counts', async () => {
|
||||||
|
const mockCounts = { active: 12, expired: 2, used: 8 }
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockCounts,
|
||||||
|
} as unknown as Response)
|
||||||
|
global.fetch = fetchMock
|
||||||
|
|
||||||
|
const result = await getInviteStatusApi()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
`${API_BASE_URL}/user/invites/status`,
|
||||||
|
expect.objectContaining({ method: 'GET' }),
|
||||||
|
)
|
||||||
|
expect(result).toEqual(mockCounts)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getInviteStatusApi throws on failure', async () => {
|
||||||
|
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ message: 'Failed to load invite counts' }),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
await expect(getInviteStatusApi()).rejects.toThrow('Failed to load invite counts')
|
||||||
|
})
|
||||||
|
|
||||||
it('createInviteApi sends POST to /user/invites and returns the created invite', async () => {
|
it('createInviteApi sends POST to /user/invites and returns the created invite', async () => {
|
||||||
const mockInvite = { id: 'invite-1', code: 'ABC123' }
|
const mockInvite = { id: 'invite-1', code: 'ABC123' }
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
|
|||||||
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { extractErrorMessage } from '@/api/http'
|
import { extractErrorMessage } from '@/api/http'
|
||||||
import type { Invite, PaginatedInvites } from '@/types/invite'
|
import type { Invite, InviteStatusCounts, PaginatedInvites } from '@/types/invite'
|
||||||
|
|
||||||
export interface GetInvitesParams {
|
export interface GetInvitesParams {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -20,6 +20,14 @@ export async function getInvitesApi(params: GetInvitesParams = {}): Promise<Pagi
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInviteStatusApi(): Promise<InviteStatusCounts> {
|
||||||
|
const response = await apiClient.get('/user/invites/status')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(response, 'Failed to load invite counts'))
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function createInviteApi(): Promise<Invite> {
|
export async function createInviteApi(): Promise<Invite> {
|
||||||
const response = await apiClient.post('/user/invites')
|
const response = await apiClient.post('/user/invites')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
|
||||||
|
import type { VersionInfo } from '@/types/version'
|
||||||
|
|
||||||
|
export async function getVersionApi(): Promise<VersionInfo> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/version`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(response, 'Failed to load API version'))
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { getInvitesApi, createInviteApi, deleteInviteApi } from '@/api/invites'
|
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '@/api/invites'
|
||||||
import type { Invite } from '@/types/invite'
|
import type { Invite, InviteStatusCounts } from '@/types/invite'
|
||||||
|
|
||||||
type InviteStatus = 'active' | 'used' | 'expired'
|
type InviteStatus = 'active' | 'used' | 'expired'
|
||||||
|
|
||||||
@@ -17,16 +17,24 @@ const error = ref('')
|
|||||||
const pendingDeleteId = ref<string | null>(null)
|
const pendingDeleteId = ref<string | null>(null)
|
||||||
const deletingId = ref<string | null>(null)
|
const deletingId = ref<string | null>(null)
|
||||||
const sharedId = ref<string | null>(null)
|
const sharedId = ref<string | null>(null)
|
||||||
|
// Counts across ALL invites (not just the current page)
|
||||||
|
const statusCounts = ref<InviteStatusCounts | null>(null)
|
||||||
|
|
||||||
let hasLoaded = false
|
let hasLoaded = false
|
||||||
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
|
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
const activeCount = computed(
|
const totalInvites = computed(() =>
|
||||||
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
|
statusCounts.value
|
||||||
|
? statusCounts.value.active + statusCounts.value.expired + statusCounts.value.used
|
||||||
|
: null,
|
||||||
)
|
)
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||||
const showPagination = computed(() => totalPages.value > 1)
|
const showPagination = computed(() => totalPages.value > 1)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadStatusCounts()
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearTimeout(sharedTimeout)
|
clearTimeout(sharedTimeout)
|
||||||
})
|
})
|
||||||
@@ -62,6 +70,16 @@ async function loadInvites() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStatusCounts() {
|
||||||
|
if (isOffline()) return
|
||||||
|
try {
|
||||||
|
statusCounts.value = await getInviteStatusApi()
|
||||||
|
} catch {
|
||||||
|
// Non-critical: the header badges just stay hidden until the next
|
||||||
|
// successful fetch instead of blocking the rest of the panel.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function goToPage(target: number) {
|
async function goToPage(target: number) {
|
||||||
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
|
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
|
||||||
return
|
return
|
||||||
@@ -82,6 +100,7 @@ async function handleCreate() {
|
|||||||
const invite = await createInviteApi()
|
const invite = await createInviteApi()
|
||||||
page.value = 1
|
page.value = 1
|
||||||
await loadInvites()
|
await loadInvites()
|
||||||
|
await loadStatusCounts()
|
||||||
// Sharing is the whole point of an invite, so offer it immediately
|
// Sharing is the whole point of an invite, so offer it immediately
|
||||||
// instead of making the user hunt for the Share button afterwards.
|
// instead of making the user hunt for the Share button afterwards.
|
||||||
await shareInvite(invite)
|
await shareInvite(invite)
|
||||||
@@ -118,6 +137,7 @@ async function confirmDelete(id: string) {
|
|||||||
page.value -= 1
|
page.value -= 1
|
||||||
}
|
}
|
||||||
await loadInvites()
|
await loadInvites()
|
||||||
|
await loadStatusCounts()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
|
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -222,7 +242,12 @@ function inviteDetail(invite: Invite): string {
|
|||||||
>
|
>
|
||||||
<span class="invites-toggle-label">
|
<span class="invites-toggle-label">
|
||||||
<h4>Invites</h4>
|
<h4>Invites</h4>
|
||||||
<span v-if="activeCount > 0" class="invites-count-badge">{{ activeCount }} active</span>
|
<span v-if="statusCounts" class="invites-stats">
|
||||||
|
<span class="invites-count-badge">{{ statusCounts.active }} active</span>
|
||||||
|
<span class="invites-count-badge badge-expired">{{ statusCounts.expired }} expired</span>
|
||||||
|
<span class="invites-count-badge badge-used">{{ statusCounts.used }} used</span>
|
||||||
|
<span class="invites-total-label">{{ totalInvites }} total</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true">⌄</span>
|
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true">⌄</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -345,6 +370,7 @@ function inviteDetail(invite: Invite): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invites-toggle-label h4 {
|
.invites-toggle-label h4 {
|
||||||
@@ -352,6 +378,13 @@ function inviteDetail(invite: Invite): string {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invites-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.invites-count-badge {
|
.invites-count-badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -361,6 +394,21 @@ function inviteDetail(invite: Invite): string {
|
|||||||
color: var(--c-accent-strong);
|
color: var(--c-accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invites-count-badge.badge-expired {
|
||||||
|
background-color: var(--c-danger-bg);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-count-badge.badge-used {
|
||||||
|
background-color: var(--c-bg-elevated);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invites-total-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
color: var(--c-text-soft);
|
color: var(--c-text-soft);
|
||||||
transition: transform 0.15s ease-in-out;
|
transition: transform 0.15s ease-in-out;
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ describe('InvitesPanel', () => {
|
|||||||
})
|
})
|
||||||
// jsdom has no Web Share API; tests that want it define it explicitly.
|
// jsdom has no Web Share API; tests that want it define it explicitly.
|
||||||
Reflect.deleteProperty(navigator, 'share')
|
Reflect.deleteProperty(navigator, 'share')
|
||||||
|
// Every mount fetches status counts once on mount; give it a harmless
|
||||||
|
// default so tests that don't care about counts don't hit real fetch.
|
||||||
|
vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||||
|
active: 0,
|
||||||
|
expired: 0,
|
||||||
|
used: 0,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders collapsed by default without loading invites', () => {
|
it('renders collapsed by default without loading invites', () => {
|
||||||
@@ -30,6 +37,115 @@ describe('InvitesPanel', () => {
|
|||||||
expect(getSpy).not.toHaveBeenCalled()
|
expect(getSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fetches invite status counts on mount and shows them in the header while collapsed', async () => {
|
||||||
|
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValueOnce({
|
||||||
|
active: 12,
|
||||||
|
expired: 2,
|
||||||
|
used: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.find('#invites-panel').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).toContain('12 active')
|
||||||
|
expect(wrapper.text()).toContain('2 expired')
|
||||||
|
expect(wrapper.text()).toContain('8 used')
|
||||||
|
expect(wrapper.text()).toContain('22 total')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fetch status counts on mount when offline', () => {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
|
||||||
|
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi')
|
||||||
|
|
||||||
|
mount(InvitesPanel)
|
||||||
|
|
||||||
|
expect(statusSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides status badges without an error banner when the status endpoint fails', async () => {
|
||||||
|
vi.spyOn(invitesApi, 'getInviteStatusApi').mockRejectedValueOnce(new Error('boom'))
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.invites-stats').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.banner-error').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not refetch status counts when paginating', async () => {
|
||||||
|
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||||
|
active: 11,
|
||||||
|
expired: 0,
|
||||||
|
used: 0,
|
||||||
|
})
|
||||||
|
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
|
||||||
|
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi')
|
||||||
|
.mockResolvedValueOnce(paginated(page1, 11))
|
||||||
|
.mockResolvedValueOnce(paginated(page2, 11))
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const [, nextBtn] = wrapper.findAll('.page-btn')
|
||||||
|
await nextBtn?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refetches status counts after creating an invite', async () => {
|
||||||
|
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||||
|
active: 1,
|
||||||
|
expired: 0,
|
||||||
|
used: 0,
|
||||||
|
})
|
||||||
|
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi')
|
||||||
|
.mockResolvedValueOnce(paginated([]))
|
||||||
|
.mockResolvedValueOnce(paginated([newInvite]))
|
||||||
|
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await wrapper.find('.generate-btn').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(statusSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refetches status counts after deleting an invite', async () => {
|
||||||
|
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||||
|
active: 0,
|
||||||
|
expired: 0,
|
||||||
|
used: 0,
|
||||||
|
})
|
||||||
|
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi')
|
||||||
|
.mockResolvedValueOnce(paginated([mockInvite]))
|
||||||
|
.mockResolvedValueOnce(paginated([]))
|
||||||
|
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await wrapper.find('.ticket-btn-danger').trigger('click')
|
||||||
|
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
|
||||||
|
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(statusSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('loads and displays invites on expand', async () => {
|
it('loads and displays invites on expand', async () => {
|
||||||
const mockInvites: Invite[] = [
|
const mockInvites: Invite[] = [
|
||||||
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
|
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ export interface PaginatedInvites {
|
|||||||
total: number
|
total: number
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteStatusCounts {
|
||||||
|
active: number
|
||||||
|
expired: number
|
||||||
|
used: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface VersionInfo {
|
||||||
|
version: string
|
||||||
|
commit: string
|
||||||
|
buildTime: string
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useListsStore } from '@/stores/lists'
|
import { useListsStore } from '@/stores/lists'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { getVersionApi } from '@/api/version'
|
||||||
|
import type { VersionInfo } from '@/types/version'
|
||||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
|
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
|
||||||
import InvitesPanel from '@/components/InvitesPanel.vue'
|
import InvitesPanel from '@/components/InvitesPanel.vue'
|
||||||
|
|
||||||
@@ -9,10 +11,27 @@ const listsStore = useListsStore()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const showChangePassword = ref(false)
|
const showChangePassword = ref(false)
|
||||||
|
const versionInfo = ref<VersionInfo | null>(null)
|
||||||
|
const versionError = ref('')
|
||||||
|
const isLoadingVersion = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
listsStore.loadLists()
|
listsStore.loadLists()
|
||||||
|
loadVersion()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadVersion() {
|
||||||
|
isLoadingVersion.value = true
|
||||||
|
versionError.value = ''
|
||||||
|
try {
|
||||||
|
versionInfo.value = await getVersionApi()
|
||||||
|
} catch (err) {
|
||||||
|
versionError.value = err instanceof Error ? err.message : 'Failed to load API information'
|
||||||
|
} finally {
|
||||||
|
isLoadingVersion.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -51,6 +70,27 @@ onMounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<h1 class="section-heading">API information</h1>
|
||||||
|
|
||||||
|
<section class="card info-card">
|
||||||
|
<p v-if="isLoadingVersion" class="loading-text">Loading…</p>
|
||||||
|
<template v-else-if="versionInfo">
|
||||||
|
<p class="row">
|
||||||
|
<span>Version</span>
|
||||||
|
<strong class="mono-num">{{ versionInfo.version }}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="row">
|
||||||
|
<span>Commit</span>
|
||||||
|
<strong class="mono-num">{{ versionInfo.commit }}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="row">
|
||||||
|
<span>Build time</span>
|
||||||
|
<strong>{{ versionInfo.buildTime }}</strong>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="banner banner-error">{{ versionError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<ChangePasswordModal v-if="showChangePassword" @close="showChangePassword = false" />
|
<ChangePasswordModal v-if="showChangePassword" @close="showChangePassword = false" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -107,4 +147,10 @@ onMounted(() => {
|
|||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user