feat: backups page cleanup before worlds (#5844)

* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

* feat: hook up admonition stacking + fix app csp for staging kyros nodes

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

* feat: loading overlay on top of console + admon consistency changes

* feat: scroll indicator fade in backup delete modal + admon timestamp fix

* feat: move action bar behind modal

* fix: lint + i18n

* fix: server ping spam on filter (cache but clear on unmount)

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

* fix: scoping
This commit is contained in:
Calum H.
2026-04-27 20:03:48 +01:00
committed by GitHub
parent 85ae1f2074
commit 620894aecb
79 changed files with 4640 additions and 1656 deletions

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -13,6 +13,7 @@
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@intercom/messenger-js-sdk": "^0.0.14",
"@modrinth/api-client": "workspace:^", "@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
import { import {
AuthFeature, AuthFeature,
NodeAuthFeature, NodeAuthFeature,
@@ -238,6 +239,7 @@ onMounted(async () => {
onUnmounted(async () => { onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick) document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
await unlistenUpdateDownload?.() await unlistenUpdateDownload?.()
}) })
@@ -652,6 +654,102 @@ const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value
const showAd = computed( const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined, () => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
) )
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const INTERCOM_DEFAULT_PADDING = 20
const INTERCOM_APP_SIDEBAR_WIDTH = 300
let intercomBooting = false
let intercomBooted = false
async function fetchIntercomToken() {
const creds = await getCreds()
if (!creds?.session) {
throw new Error('Not authenticated')
}
const params = new URLSearchParams()
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
params.set('server_id', route.params.id)
}
const query = params.size > 0 ? `?${params.toString()}` : ''
const response = await tauriFetch(`${config.siteUrl}/api/intercom/messenger-jwt${query}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${creds.session}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
}
return await response.json()
}
async function bootIntercom() {
if (
intercomBooting ||
intercomBooted ||
!hostingRouteActive.value ||
!credentials.value?.session
) {
return
}
intercomBooting = true
console.debug('[APP][INTERCOM] initializing secure support chat')
try {
const { token } = await fetchIntercomToken()
Intercom({
app_id: 'ykeritl9',
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
alignment: 'right',
horizontal_padding: sidebarVisible.value
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
: INTERCOM_DEFAULT_PADDING,
vertical_padding: INTERCOM_DEFAULT_PADDING,
})
intercomBooted = true
} catch (error) {
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
} finally {
intercomBooting = false
}
}
function shutdownHostingIntercom() {
if (!intercomBooted && !intercomBooting) return
shutdownIntercom()
intercomBooting = false
intercomBooted = false
}
watch(
sidebarVisible,
(visible) => {
if (intercomBooted) {
window.Intercom?.('update', {
horizontal_padding: visible
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
: INTERCOM_DEFAULT_PADDING,
vertical_padding: INTERCOM_DEFAULT_PADDING,
})
}
},
{ immediate: true },
)
watch(
[hostingRouteActive, credentials],
([active]) => {
if (active) {
void bootIntercom()
} else {
shutdownHostingIntercom()
}
},
{ immediate: true },
)
watch(showAd, () => { watch(showAd, () => {
if (!showAd.value) { if (!showAd.value) {

View File

@@ -180,13 +180,6 @@ img {
} }
} }
button,
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
@import '@modrinth/assets/omorphia.scss'; @import '@modrinth/assets/omorphia.scss';
input { input {

View File

@@ -30,10 +30,10 @@
"message": "Discover servers" "message": "Discover servers"
}, },
"app.browse.hide-added-servers": { "app.browse.hide-added-servers": {
"message": "Hide added servers" "message": "Hide already added servers"
}, },
"app.browse.hide-installed-content": { "app.browse.hide-installed-content": {
"message": "Hide installed content" "message": "Hide already installed content"
}, },
"app.browse.install-content-to-instance": { "app.browse.install-content-to-instance": {
"message": "Install content to instance" "message": "Install content to instance"

View File

@@ -127,6 +127,8 @@ const instance: Ref<Instance | null> = ref(null)
const installedProjectIds: Ref<string[] | null> = ref(null) const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false) const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([]) const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false) const isServerInstance = ref(false)
if (isFromWorlds.value && route.params.projectType !== 'server') { if (isFromWorlds.value && route.params.projectType !== 'server') {
@@ -142,6 +144,25 @@ const allInstalledIds = computed(
() => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]), () => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]),
) )
function syncHiddenInstanceProjectIds() {
hiddenInstanceProjectIds.value = new Set([
...(installedProjectIds.value ?? []),
...newlyInstalled.value,
])
hiddenInstanceProjectIdsInitialized.value = true
}
watch(
installedProjectIds,
(ids) => {
if (!ids) return
if (!hiddenInstanceProjectIdsInitialized.value) {
syncHiddenInstanceProjectIds()
}
},
{ immediate: true },
)
watchServerContextChanges() watchServerContextChanges()
await initInstanceContext() await initInstanceContext()
@@ -222,14 +243,10 @@ const instanceFilters = computed(() => {
filters.push({ type: 'environment', option: 'client' }) filters.push({ type: 'environment', option: 'client' })
} }
if ( if (instanceHideInstalled.value && hiddenInstanceProjectIds.value.size > 0) {
instanceHideInstalled.value && for (const id of hiddenInstanceProjectIds.value) {
(installedProjectIds.value || newlyInstalled.value.length > 0) filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
) { }
const allInstalled = [...(installedProjectIds.value ?? []), ...newlyInstalled.value]
allInstalled
.map((x) => ({ type: 'project_id', option: `project_id:${x}`, negative: true }))
.forEach((x) => filters.push(x))
} }
} }
@@ -240,6 +257,23 @@ const serverHideInstalled = ref(false)
if (route.query.shi) { if (route.query.shi) {
serverHideInstalled.value = route.query.shi === 'true' serverHideInstalled.value = route.query.shi === 'true'
} }
const hiddenServerContentProjectIds = ref<Set<string>>(new Set())
const hiddenServerContentProjectIdsInitialized = ref(false)
function syncHiddenServerContentProjectIds() {
hiddenServerContentProjectIds.value = new Set(serverContentProjectIds.value)
hiddenServerContentProjectIdsInitialized.value = true
}
watch(
serverContentProjectIds,
() => {
if (!hiddenServerContentProjectIdsInitialized.value) {
syncHiddenServerContentProjectIds()
}
},
{ immediate: true },
)
const serverContextFilters = computed(() => { const serverContextFilters = computed(() => {
const filters: { type: string; option: string; negative?: boolean }[] = [] const filters: { type: string; option: string; negative?: boolean }[] = []
@@ -266,8 +300,8 @@ const serverContextFilters = computed(() => {
) )
} }
if (serverHideInstalled.value && serverContentProjectIds.value.size > 0) { if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
for (const id of serverContentProjectIds.value) { for (const id of hiddenServerContentProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true }) filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
} }
} }
@@ -280,6 +314,9 @@ const combinedProvidedFilters = computed(() =>
) )
const serverPings = shallowRef<Record<string, number | undefined>>({}) const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
let serverPingCacheActive = true
const runningServerProjects = ref<Record<string, string>>({}) const runningServerProjects = ref<Record<string, string>>({})
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) { async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
@@ -342,16 +379,44 @@ async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearc
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) { async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('pingServerHits', { hitCount: hits.length }) debugLog('pingServerHits', { hitCount: hits.length })
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address) const pingsToFetch = hits.flatMap((hit) => {
const address = hit.minecraft_java_server?.address
if (!address) return []
return [{ hit, address }]
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
}
}
serverPings.value = nextPings
await Promise.all( await Promise.all(
pingsToFetch.map(async (hit) => { pingsToFetch.map(async ({ hit, address }) => {
const address = hit.minecraft_java_server!.address! if (serverPingCache.has(address)) return
try {
const latency = await getServerLatency(address) let pending = pendingServerPings.get(address)
serverPings.value = { ...serverPings.value, [hit.project_id]: latency } if (!pending) {
} catch (err) { pending = getServerLatency(address)
console.error(`Failed to ping server ${address}:`, err) .then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((err) => {
console.error(`Failed to ping server ${address}:`, err)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
} }
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}), }),
) )
} }
@@ -372,7 +437,10 @@ const unlistenProcesses = await process_listener(
) )
onUnmounted(() => { onUnmounted(() => {
serverPingCacheActive = false
unlistenProcesses() unlistenProcesses()
serverPingCache.clear()
pendingServerPings.clear()
}) })
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine)
@@ -432,11 +500,11 @@ const messages = defineMessages({
}, },
hideAddedServers: { hideAddedServers: {
id: 'app.browse.hide-added-servers', id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide added servers', defaultMessage: 'Hide already added servers',
}, },
hideInstalledContent: { hideInstalledContent: {
id: 'app.browse.hide-installed-content', id: 'app.browse.hide-installed-content',
defaultMessage: 'Hide installed content', defaultMessage: 'Hide already installed content',
}, },
installContentToInstance: { installContentToInstance: {
id: 'app.browse.install-content-to-instance', id: 'app.browse.install-content-to-instance',
@@ -663,6 +731,16 @@ const installContext = computed(() => {
const installingProjectIds = ref<Set<string>>(new Set()) const installingProjectIds = ref<Set<string>>(new Set())
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
next.add(projectId)
} else {
next.delete(projectId)
}
installingProjectIds.value = next
}
function getCardActions( function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject, result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string, currentProjectType: string,
@@ -757,11 +835,12 @@ function getCardActions(
: messages.installToServer, : messages.installToServer,
), ),
icon: isInstalled ? CheckIcon : PlusIcon, icon: isInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling, disabled: isInstalled || isInstalling,
color: 'brand', color: 'brand',
type: 'outlined', type: 'outlined',
onClick: async () => { onClick: async () => {
installingProjectIds.value.add(projectResult.project_id) setProjectInstalling(projectResult.project_id, true)
try { try {
const didInstall = await installProjectToServer(projectResult) const didInstall = await installProjectToServer(projectResult)
if (didInstall !== false) { if (didInstall !== false) {
@@ -770,7 +849,7 @@ function getCardActions(
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally { } finally {
installingProjectIds.value.delete(projectResult.project_id) setProjectInstalling(projectResult.project_id, false)
} }
}, },
}, },
@@ -791,18 +870,19 @@ function getCardActions(
? 'Install' ? 'Install'
: 'Add to an instance', : 'Add to an instance',
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon, icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling, disabled: isInstalled || isInstalling,
color: 'brand', color: 'brand',
type: 'outlined', type: 'outlined',
onClick: async () => { onClick: async () => {
installingProjectIds.value.add(projectResult.project_id) setProjectInstalling(projectResult.project_id, true)
await installVersion( await installVersion(
projectResult.project_id, projectResult.project_id,
null, null,
instance.value ? instance.value.path : null, instance.value ? instance.value.path : null,
'SearchCard', 'SearchCard',
(versionId) => { (versionId) => {
installingProjectIds.value.delete(projectResult.project_id) setProjectInstalling(projectResult.project_id, false)
if (versionId) { if (versionId) {
onSearchResultInstalled(projectResult.project_id) onSearchResultInstalled(projectResult.project_id)
} }
@@ -815,7 +895,7 @@ function getCardActions(
preferredGameVersion: instance.value?.game_version ?? undefined, preferredGameVersion: instance.value?.game_version ?? undefined,
}, },
).catch((err) => { ).catch((err) => {
installingProjectIds.value.delete(projectResult.project_id) setProjectInstalling(projectResult.project_id, false)
handleError(err) handleError(err)
}) })
}, },
@@ -858,7 +938,6 @@ async function search(requestParams: string) {
if (isServer) { if (isServer) {
const hits = rawResults.result.hits ?? [] const hits = rawResults.result.hits ?? []
lastServerHits.value = hits lastServerHits.value = hits
serverPings.value = {}
pingServerHits(hits) pingServerHits(hits)
checkServerRunningStates(hits) checkServerRunningStates(hits)
return { return {
@@ -928,6 +1007,23 @@ const searchState = useBrowseSearch({
}), }),
}) })
watch(
[
() => searchState.query.value,
() => searchState.currentFilters.value,
() => searchState.serverCurrentFilters.value,
() => projectType.value,
],
() => {
if (isServerContext.value) {
syncHiddenServerContentProjectIds()
} else if (instance.value) {
syncHiddenInstanceProjectIds()
}
},
{ deep: true },
)
if (instance.value?.game_version) { if (instance.value?.game_version) {
const gv = instance.value.game_version const gv = instance.value.game_version
const alreadyHasGv = searchState.serverCurrentFilters.value.some( const alreadyHasGv = searchState.serverCurrentFilters.value.some(
@@ -959,8 +1055,13 @@ provideBrowseManager({
hideInstalled: computed({ hideInstalled: computed({
get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value), get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value),
set: (val: boolean) => { set: (val: boolean) => {
if (isServerContext.value) serverHideInstalled.value = val if (isServerContext.value) {
else instanceHideInstalled.value = val serverHideInstalled.value = val
if (val) syncHiddenServerContentProjectIds()
} else {
instanceHideInstalled.value = val
if (val) syncHiddenInstanceProjectIds()
}
}, },
}), }),
showHideInstalled: computed( showHideInstalled: computed(

View File

@@ -6,7 +6,6 @@
:resolve-viewer="resolveViewer" :resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode" :show-copy-id-action="themeStore.devMode"
:auth-user="authUser" :auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')" :navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')" :navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks=" :browse-modpacks="
@@ -47,12 +46,10 @@
import type { Archon, Labrinth } from '@modrinth/api-client' import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui' import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache' import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth' import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
@@ -123,26 +120,6 @@ const authUser = computed(() => {
} }
}) })
async function fetchIntercomToken(): Promise<{ token: string }> {
const credentials = await getCreds()
if (!credentials?.session) {
throw new Error('Not authenticated')
}
const response = await tauriFetch(
`${config.siteUrl}/api/intercom/messenger-jwt?server_id=${encodeURIComponent(serverId.value)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${credentials.session}`,
},
},
)
if (!response.ok) {
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
}
return (await response.json()) as { token: string }
}
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> { async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
const credentials = await getCreds().catch(() => null) const credentials = await getCreds().catch(() => null)
if (!credentials?.user_id) { if (!credentials?.user_id) {

View File

@@ -240,7 +240,7 @@ export default new createRouter({
component: Instance.Logs, component: Instance.Logs,
meta: { meta: {
useRootContext: true, useRootContext: true,
renderMode: 'fixed', // renderMode: 'fixed',
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }], breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
}, },
}, },

View File

@@ -2,7 +2,9 @@
"identifier": "plugins", "identifier": "plugins",
"description": "", "description": "",
"local": true, "local": true,
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-confirm", "dialog:allow-confirm",
@@ -19,21 +21,36 @@
"window-state:default", "window-state:default",
"window-state:allow-restore-state", "window-state:allow-restore-state",
"window-state:allow-save-window-state", "window-state:allow-save-window-state",
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [ "allow": [
{ "url": "https://modrinth.com/*" }, {
{ "url": "https://*.modrinth.com/*" }, "url": "https://modrinth.com/*"
{ "url": "https://*.nodes.modrinth.com/*" }, },
{ "url": "https://api.mclo.gs/*" }, {
{ "url": "https://fill.papermc.io/*" }, "url": "https://*.modrinth.com/*"
{ "url": "https://api.purpurmc.org/*" } },
{
"url": "https://*.nodes.modrinth.com/*"
},
{
"url": "https://api.mclo.gs/*"
},
{
"url": "https://fill.papermc.io/*"
},
{
"url": "https://api.purpurmc.org/*"
},
{
"url": "http://*.taila228c5.ts.net/*"
},
{
"url": "https://*.taila228c5.ts.net/*"
}
] ]
}, },
"dialog:allow-save", "dialog:allow-save",
"fs:allow-read-dir", "fs:allow-read-dir",
"fs:allow-read-file", "fs:allow-read-file",
"fs:allow-read-text-file", "fs:allow-read-text-file",
@@ -49,15 +66,26 @@
{ {
"identifier": "fs:scope", "identifier": "fs:scope",
"allow": [ "allow": [
{ "path": "$APPDATA/profiles" }, {
{ "path": "$APPDATA/profiles/**" }, "path": "$APPDATA/profiles"
{ "path": "$APPCONFIG/profiles" }, },
{ "path": "$APPCONFIG/profiles/**" }, {
{ "path": "$CONFIG/profiles" }, "path": "$APPDATA/profiles/**"
{ "path": "$CONFIG/profiles/**" } },
{
"path": "$APPCONFIG/profiles"
},
{
"path": "$APPCONFIG/profiles/**"
},
{
"path": "$CONFIG/profiles"
},
{
"path": "$CONFIG/profiles/**"
}
] ]
}, },
"auth:default", "auth:default",
"import:default", "import:default",
"jre:default", "jre:default",

View File

@@ -12,7 +12,12 @@
"copyright": "", "copyright": "",
"targets": "all", "targets": "all",
"externalBin": [], "externalBin": [],
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "icon": [
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": { "windows": {
"nsis": { "nsis": {
"installMode": "currentUser", "installMode": "currentUser",
@@ -35,7 +40,9 @@
}, },
"fileAssociations": [ "fileAssociations": [
{ {
"ext": ["mrpack"], "ext": [
"mrpack"
],
"mimeType": "application/x-modrinth-modpack+zip" "mimeType": "application/x-modrinth-modpack+zip"
} }
] ]
@@ -47,7 +54,9 @@
"plugins": { "plugins": {
"deep-link": { "deep-link": {
"desktop": { "desktop": {
"schemes": ["modrinth"] "schemes": [
"modrinth"
]
}, },
"mobile": [] "mobile": []
} }
@@ -84,15 +93,22 @@
], ],
"enable": true "enable": true
}, },
"capabilities": ["ads", "core", "plugins"], "capabilities": [
"ads",
"core",
"plugins"
],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset:", "default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:", "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com https://app.getsentry.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"], "font-src": [
"https://cdn-raw.modrinth.com/fonts/",
"https://js.intercomcdn.com"
],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'", "style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://tally.so/widgets/embed.js 'self'", "script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://tally.so/widgets/embed.js 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com 'self'", "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'",
"media-src": "https://*.githubusercontent.com" "media-src": "https://*.githubusercontent.com"
} }
} }

1
apps/frontend/AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -1,3 +1,5 @@
@import '@modrinth/assets/styles/reset.scss';
html { html {
--dark-color-text: #b0bac5; --dark-color-text: #b0bac5;
--dark-color-text-dark: #ecf9fb; --dark-color-text-dark: #ecf9fb;

View File

@@ -9,6 +9,7 @@ import {
ImageIcon, ImageIcon,
ListIcon, ListIcon,
MoreVerticalIcon, MoreVerticalIcon,
SpinnerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import type { CardAction, CreationFlowContextValue } from '@modrinth/ui' import type { CardAction, CreationFlowContextValue } from '@modrinth/ui'
import { import {
@@ -172,6 +173,43 @@ const serverIcon = computed(() => {
}) })
const serverHideInstalled = ref(false) const serverHideInstalled = ref(false)
const installingProjectIds = ref<Set<string>>(new Set())
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIdsInitialized = ref(false)
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
next.add(projectId)
} else {
next.delete(projectId)
}
installingProjectIds.value = next
}
function markProjectInstalled(projectId: string) {
optimisticallyInstalledProjectIds.value = new Set([
...optimisticallyInstalledProjectIds.value,
projectId,
])
}
function getServerInstalledProjectIds(data = serverContentData.value) {
return new Set(
(data?.addons ?? [])
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
}
function syncHiddenInstalledProjectIds() {
hiddenInstalledProjectIds.value = new Set([
...getServerInstalledProjectIds(),
...optimisticallyInstalledProjectIds.value,
])
hiddenInstalledProjectIdsInitialized.value = true
}
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const) const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
const { data: serverContentData, error: serverContentError } = useQuery({ const { data: serverContentData, error: serverContentError } = useQuery({
@@ -187,6 +225,17 @@ watch(serverContentError, (error) => {
} }
}) })
watch(
serverContentData,
(data) => {
if (!data) return
if (!hiddenInstalledProjectIdsInitialized.value) {
syncHiddenInstalledProjectIds()
}
},
{ immediate: true },
)
const installContentMutation = useMutation({ const installContentMutation = useMutation({
mutationFn: ({ mutationFn: ({
serverId, serverId,
@@ -212,6 +261,12 @@ if (route.query.shi && projectType.value?.id !== 'modpack') {
serverHideInstalled.value = route.query.shi === 'true' serverHideInstalled.value = route.query.shi === 'true'
} }
watch(serverHideInstalled, (hideInstalled) => {
if (hideInstalled) {
syncHiddenInstalledProjectIds()
}
})
const serverFilters = computed(() => { const serverFilters = computed(() => {
debug( debug(
'serverFilters recomputing, serverData:', 'serverFilters recomputing, serverData:',
@@ -242,19 +297,14 @@ const serverFilters = computed(() => {
filters.push({ type: 'environment', option: 'server' }) filters.push({ type: 'environment', option: 'server' })
} }
if (serverHideInstalled.value && serverContentData.value) { if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
const installedIds = (serverContentData.value.addons ?? []) for (const x of hiddenInstalledProjectIds.value) {
.filter((x) => x.project_id) filters.push({
.map((x) => x.project_id)
.filter((id): id is string => id !== null)
installedIds
.map((x: string) => ({
type: 'project_id', type: 'project_id',
option: `project_id:${x}`, option: `project_id:${x}`,
negative: true, negative: true,
})) })
.forEach((x) => filters.push(x)) }
} }
} }
@@ -269,7 +319,6 @@ const serverFilters = computed(() => {
}) })
interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject { interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject {
installing?: boolean
installed?: boolean installed?: boolean
} }
@@ -278,7 +327,7 @@ async function serverInstall(project: InstallableSearchResult) {
handleError(new Error('No server to install to.')) handleError(new Error('No server to install to.'))
return return
} }
project.installing = true setProjectInstalling(project.project_id, true)
try { try {
if (projectType.value?.id === 'modpack') { if (projectType.value?.id === 'modpack') {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
@@ -287,7 +336,7 @@ async function serverInstall(project: InstallableSearchResult) {
const versionId = versions[0]?.id ?? project.latest_version const versionId = versions[0]?.id ?? project.latest_version
if (!versionId) { if (!versionId) {
handleError(new Error('No version found for this modpack')) handleError(new Error('No version found for this modpack'))
project.installing = false setProjectInstalling(project.project_id, false)
return return
} }
const modalInstance = onboardingModalRef.value const modalInstance = onboardingModalRef.value
@@ -326,7 +375,7 @@ async function serverInstall(project: InstallableSearchResult) {
: `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`, : `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`,
), ),
) )
project.installing = false setProjectInstalling(project.project_id, false)
return return
} }
await installContentMutation.mutateAsync({ await installContentMutation.mutateAsync({
@@ -334,13 +383,13 @@ async function serverInstall(project: InstallableSearchResult) {
projectId: version.project_id, projectId: version.project_id,
versionId: version.id, versionId: version.id,
}) })
project.installed = true markProjectInstalled(project.project_id)
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
handleError(new Error(`Error installing content ${e}`)) handleError(new Error(`Error installing content ${e}`))
} }
project.installing = false setProjectInstalling(project.project_id, false)
} }
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) { function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
@@ -447,16 +496,19 @@ function getCardActions(
if (serverData.value) { if (serverData.value) {
const isInstalled = const isInstalled =
projectResult.installed || projectResult.installed ||
optimisticallyInstalledProjectIds.value.has(result.project_id) ||
(serverContentData.value && (serverContentData.value &&
(serverContentData.value.addons ?? []).find((x) => x.project_id === result.project_id)) || (serverContentData.value.addons ?? []).find((x) => x.project_id === result.project_id)) ||
serverData.value.upstream?.project_id === result.project_id serverData.value.upstream?.project_id === result.project_id
const isInstalling = installingProjectIds.value.has(result.project_id)
return [ return [
{ {
key: 'install', key: 'install',
label: projectResult.installing ? 'Installing...' : isInstalled ? 'Installed' : 'Install', label: isInstalling ? 'Installing...' : isInstalled ? 'Installed' : 'Install',
icon: isInstalled ? CheckIcon : DownloadIcon, icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : DownloadIcon,
disabled: !!isInstalled || !!projectResult.installing, iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: !!isInstalled || isInstalling,
color: 'brand', color: 'brand',
type: 'outlined', type: 'outlined',
onClick: () => serverInstall(projectResult), onClick: () => serverInstall(projectResult),
@@ -472,7 +524,7 @@ const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
function onOnboardingHide() { function onOnboardingHide() {
if (onboardingInstallingProject.value) { if (onboardingInstallingProject.value) {
onboardingInstallingProject.value.installing = false setProjectInstalling(onboardingInstallingProject.value.project_id, false)
onboardingInstallingProject.value = null onboardingInstallingProject.value = null
} }
} }
@@ -587,6 +639,19 @@ const searchState = useBrowseSearch({
displayMode: resultsDisplayMode, displayMode: resultsDisplayMode,
}) })
watch(
[
() => searchState.query.value,
() => searchState.currentFilters.value,
() => searchState.serverCurrentFilters.value,
() => projectTypeId.value,
],
() => {
syncHiddenInstalledProjectIds()
},
{ deep: true },
)
debug('calling initial refreshSearch') debug('calling initial refreshSearch')
searchState.refreshSearch() searchState.refreshSearch()
@@ -622,7 +687,7 @@ provideBrowseManager({
providedFilters: serverFilters, providedFilters: serverFilters,
hideInstalled: serverHideInstalled, hideInstalled: serverHideInstalled,
showHideInstalled: computed(() => !!serverData.value && projectType.value?.id !== 'modpack'), showHideInstalled: computed(() => !!serverData.value && projectType.value?.id !== 'modpack'),
hideInstalledLabel: computed(() => 'Hide installed content'), hideInstalledLabel: computed(() => 'Hide already installed content'),
displayMode: resultsDisplayMode, displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode, cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions, maxResultsOptions: currentMaxResultsOptions,

1
apps/labrinth/AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -37,7 +37,7 @@ client.labrinth.collections
client.labrinth.billing_internal client.labrinth.billing_internal
client.archon.servers_v0 client.archon.servers_v0
client.archon.servers_v1 client.archon.servers_v1
client.archon.backups_v0 client.archon.backups_queue_v1
client.archon.backups_v1 client.archon.backups_v1
client.archon.content_v0 client.archon.content_v0
client.kyros.files_v0 client.kyros.files_v0

View File

@@ -9,6 +9,11 @@ import { AbstractUploadClient } from './abstract-upload-client'
import type { AbstractWebSocketClient } from './abstract-websocket' import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors' import { ModrinthApiError, ModrinthServerError } from './errors'
type ArchonClientModules = Omit<InferredClientModules['archon'], 'backups_v1'> & {
/** @deprecated Use `backups_queue_v1` for the Backups Queue API. */
backups_v1: InferredClientModules['archon']['backups_v1']
}
/** /**
* Abstract base client for Modrinth APIs * Abstract base client for Modrinth APIs
*/ */
@@ -27,7 +32,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map() private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
public readonly labrinth!: InferredClientModules['labrinth'] public readonly labrinth!: InferredClientModules['labrinth']
public readonly archon!: InferredClientModules['archon'] & { sockets: AbstractWebSocketClient } public readonly archon!: ArchonClientModules & { sockets: AbstractWebSocketClient }
public readonly kyros!: InferredClientModules['kyros'] public readonly kyros!: InferredClientModules['kyros']
public readonly iso3166!: InferredClientModules['iso3166'] public readonly iso3166!: InferredClientModules['iso3166']
public readonly mclogs!: InferredClientModules['mclogs'] public readonly mclogs!: InferredClientModules['mclogs']

View File

@@ -0,0 +1,93 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonBackupsQueueV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_backups_queue_v1'
}
/** GET /v1/servers/:server_id/worlds/:world_id/backups-queue */
public async list(
serverId: string,
worldId: string,
): Promise<Archon.BackupsQueue.v1.BackupsQueueResponse> {
return this.client.request<Archon.BackupsQueue.v1.BackupsQueueResponse>(
`/servers/${serverId}/worlds/${worldId}/backups-queue`,
{ api: 'archon', version: 1, method: 'GET' },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue */
public async create(
serverId: string,
worldId: string,
request: Archon.BackupsQueue.v1.BackupRequest,
): Promise<Archon.BackupsQueue.v1.PostBackupQueueResponse> {
return this.client.request<Archon.BackupsQueue.v1.PostBackupQueueResponse>(
`/servers/${serverId}/worlds/${worldId}/backups-queue`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/history/create/:operation_id/ack */
public async ackCreate(serverId: string, worldId: string, operationId: number): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/history/create/${operationId}/ack`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/history/restore/:operation_id/ack */
public async ackRestore(serverId: string, worldId: string, operationId: number): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/history/restore/${operationId}/ack`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
/** DELETE /v1/servers/:server_id/worlds/:world_id/backups-queue/:backup_id */
public async delete(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/${backupId}`,
{
api: 'archon',
version: 1,
method: 'DELETE',
},
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/delete-many */
public async deleteMany(serverId: string, worldId: string, backupIds: string[]): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/delete-many`,
{
api: 'archon',
version: 1,
method: 'POST',
body: { backup_ids: backupIds } satisfies Archon.BackupsQueue.v1.DeleteManyBackupRequest,
},
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/:backup_id/restore */
public async restore(
serverId: string,
worldId: string,
backupId: string,
request: Archon.BackupsQueue.v1.BackupRequest,
): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/${backupId}/restore`,
{ api: 'archon', version: 1, method: 'POST', body: request },
)
}
/** POST /v1/servers/:server_id/worlds/:world_id/backups-queue/:backup_id/retry */
public async retry(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(
`/servers/${serverId}/worlds/${worldId}/backups-queue/${backupId}/retry`,
{ api: 'archon', version: 1, method: 'POST' },
)
}
}

View File

@@ -1,11 +1,17 @@
import { AbstractModule } from '../../../core/abstract-module' import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types' import type { Archon } from '../types'
/**
* @deprecated Use `client.archon.backups_queue_v1` (Backups Queue API) instead.
*/
export class ArchonBackupsV1Module extends AbstractModule { export class ArchonBackupsV1Module extends AbstractModule {
public getModuleID(): string { public getModuleID(): string {
return 'archon_backups_v1' return 'archon_backups_v1'
} }
/**
* @deprecated Use `client.archon.backups_queue_v1.list` instead.
*/
/** GET /v1/servers/:server_id/worlds/:world_id/backups */ /** GET /v1/servers/:server_id/worlds/:world_id/backups */
public async list(serverId: string, worldId: string): Promise<Archon.Backups.v1.Backup[]> { public async list(serverId: string, worldId: string): Promise<Archon.Backups.v1.Backup[]> {
return this.client.request<Archon.Backups.v1.Backup[]>( return this.client.request<Archon.Backups.v1.Backup[]>(
@@ -14,6 +20,9 @@ export class ArchonBackupsV1Module extends AbstractModule {
) )
} }
/**
* @deprecated Use `client.archon.backups_queue_v1.list` instead.
*/
/** GET /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */ /** GET /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async get( public async get(
serverId: string, serverId: string,
@@ -26,6 +35,9 @@ export class ArchonBackupsV1Module extends AbstractModule {
) )
} }
/**
* @deprecated Use `client.archon.backups_queue_v1.create` instead.
*/
/** POST /v1/servers/:server_id/worlds/:world_id/backups */ /** POST /v1/servers/:server_id/worlds/:world_id/backups */
public async create( public async create(
serverId: string, serverId: string,
@@ -38,6 +50,9 @@ export class ArchonBackupsV1Module extends AbstractModule {
) )
} }
/**
* @deprecated Use `client.archon.backups_queue_v1.restore` instead.
*/
/** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/restore */ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/restore */
public async restore(serverId: string, worldId: string, backupId: string): Promise<void> { public async restore(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>( await this.client.request<void>(
@@ -50,6 +65,9 @@ export class ArchonBackupsV1Module extends AbstractModule {
) )
} }
/**
* @deprecated Use `client.archon.backups_queue_v1.delete` instead.
*/
/** DELETE /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */ /** DELETE /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async delete(serverId: string, worldId: string, backupId: string): Promise<void> { public async delete(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, { await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/backups/${backupId}`, {
@@ -59,6 +77,9 @@ export class ArchonBackupsV1Module extends AbstractModule {
}) })
} }
/**
* @deprecated Use `client.archon.backups_queue_v1.retry` instead.
*/
/** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/retry */ /** POST /v1/servers/:server_id/worlds/:world_id/backups/:backup_id/retry */
public async retry(serverId: string, worldId: string, backupId: string): Promise<void> { public async retry(serverId: string, worldId: string, backupId: string): Promise<void> {
await this.client.request<void>( await this.client.request<void>(
@@ -71,6 +92,9 @@ export class ArchonBackupsV1Module extends AbstractModule {
) )
} }
/**
* @deprecated Legacy backups only; no queue equivalent. Prefer renaming via other supported flows if available.
*/
/** PATCH /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */ /** PATCH /v1/servers/:server_id/worlds/:world_id/backups/:backup_id */
public async rename( public async rename(
serverId: string, serverId: string,

View File

@@ -1,4 +1,5 @@
export * from './backups/v1' export * from './backups/v1'
export * from './backups-queue/v1'
export * from './content/v1' export * from './content/v1'
export * from './properties/v1' export * from './properties/v1'
export * from './servers/v0' export * from './servers/v0'

View File

@@ -404,6 +404,9 @@ export namespace Archon {
name: string name: string
created_at: string created_at: string
is_active: boolean is_active: boolean
/**
* @deprecated Prefer `client.archon.backups_queue_v1.list()` for queue-aware backup state.
*/
backups: Archon.Backups.v1.Backup[] backups: Archon.Backups.v1.Backup[]
content: WorldContentInfo | null content: WorldContentInfo | null
readiness: WorldReadiness readiness: WorldReadiness
@@ -434,16 +437,24 @@ export namespace Archon {
} }
export namespace Backups { export namespace Backups {
/**
* @deprecated Use {@link Archon.BackupsQueue.v1} and `client.archon.backups_queue_v1` instead.
*/
export namespace v1 { export namespace v1 {
/** @deprecated Use {@link Archon.BackupsQueue.v1} instead. */
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged' export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
/** @deprecated Use {@link Archon.BackupsQueue.v1} instead. */
export type BackupTask = 'file' | 'create' | 'restore' export type BackupTask = 'file' | 'create' | 'restore'
/** @deprecated Use {@link Archon.BackupsQueue.v1} instead. */
export type BackupStatus = 'pending' | 'in_progress' | 'timed_out' | 'error' | 'done' export type BackupStatus = 'pending' | 'in_progress' | 'timed_out' | 'error' | 'done'
/** @deprecated Use {@link Archon.BackupsQueue.v1} instead. */
export type BackupTaskProgress = { export type BackupTaskProgress = {
progress: number // 0.0 to 1.0 progress: number // 0.0 to 1.0
state: BackupState state: BackupState
} }
/** @deprecated Use {@link Archon.BackupsQueue.v1.BackupQueueBackup} instead. */
export type Backup = { export type Backup = {
id: string id: string
physical_id: string physical_id: string
@@ -461,20 +472,87 @@ export namespace Archon {
} }
} }
/** @deprecated Use {@link Archon.BackupsQueue.v1.BackupRequest} instead. */
export type BackupRequest = { export type BackupRequest = {
name: string name: string
} }
/** @deprecated Use {@link Archon.BackupsQueue.v1} instead. */
export type PatchBackup = { export type PatchBackup = {
name?: string name?: string
} }
/** @deprecated Use {@link Archon.BackupsQueue.v1.PostBackupQueueResponse} instead. */
export type PostBackupResponse = { export type PostBackupResponse = {
id: string id: string
} }
} }
} }
export namespace BackupsQueue {
export namespace v1 {
export type BackupQueueOperationType = 'create' | 'restore'
export type BackupQueueState =
| 'pending'
| 'ongoing'
| 'completed'
| 'cancelled'
| 'failed'
| 'timed_out'
export type BackupStatus = 'pending' | 'in_progress' | 'timed_out' | 'error' | 'done'
export type BackupRequest = {
name: string
}
export type PostBackupQueueResponse = {
id: string
}
export type DeleteManyBackupRequest = {
backup_ids: string[]
}
export type ActiveOperation = {
backup_id: string
operation_type: BackupQueueOperationType
operation_id?: number | null
has_parent: boolean
scheduled_for: string
synthetic_legacy: boolean
}
export type BackupQueueOperation = {
operation_type: BackupQueueOperationType
operation_id?: number | null
state: BackupQueueState
scheduled_for: string
completed_at?: string | null
has_parent: boolean
error?: string | null
should_prompt: boolean
synthetic_legacy: boolean
}
export type BackupQueueBackup = {
id: string
name: string
created_at: string
status: BackupStatus
locked: boolean
automated: boolean
history: BackupQueueOperation[]
}
export type BackupsQueueResponse = {
active_operations: ActiveOperation[]
backups: BackupQueueBackup[]
}
}
}
export namespace Websocket { export namespace Websocket {
export namespace v0 { export namespace v0 {
export type WSAuth = { export type WSAuth = {
@@ -482,7 +560,14 @@ export namespace Archon {
token: string token: string
} }
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged' export type BackupState =
| 'pending'
| 'ongoing'
| 'done'
| 'failed'
| 'cancelled'
| 'unchanged'
| 'damaged'
export type BackupTask = 'file' | 'create' | 'restore' export type BackupTask = 'file' | 'create' | 'restore'
export type WSBackupProgressEvent = { export type WSBackupProgressEvent = {
@@ -491,6 +576,8 @@ export namespace Archon {
task: BackupTask task: BackupTask
state: BackupState state: BackupState
progress: number progress: number
start_time?: number | null
finish_time?: number | null
} }
export type WSLogEvent = { export type WSLogEvent = {

View File

@@ -1,6 +1,7 @@
import type { AbstractModrinthClient } from '../core/abstract-client' import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module' import type { AbstractModule } from '../core/abstract-module'
import { ArchonBackupsV1Module } from './archon/backups/v1' import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1'
import { ArchonContentV1Module } from './archon/content/v1' import { ArchonContentV1Module } from './archon/content/v1'
import { ArchonOptionsV1Module } from './archon/options/v1' import { ArchonOptionsV1Module } from './archon/options/v1'
import { ArchonPropertiesV1Module } from './archon/properties/v1' import { ArchonPropertiesV1Module } from './archon/properties/v1'
@@ -55,6 +56,7 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
* TODO: Better way? Probably not * TODO: Better way? Probably not
*/ */
export const MODULE_REGISTRY = { export const MODULE_REGISTRY = {
archon_backups_queue_v1: ArchonBackupsQueueV1Module,
archon_backups_v1: ArchonBackupsV1Module, archon_backups_v1: ArchonBackupsV1Module,
archon_content_v1: ArchonContentV1Module, archon_content_v1: ArchonContentV1Module,
archon_options_v1: ArchonOptionsV1Module, archon_options_v1: ArchonOptionsV1Module,

View File

@@ -1,3 +1,5 @@
@import 'reset';
// Use border box on everything to preserve everyone's sanity // Use border box on everything to preserve everyone's sanity
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -104,13 +106,6 @@ svg {
} }
} }
button,
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
input, input,
button { button {
&:disabled { &:disabled {

View File

@@ -0,0 +1,11 @@
button,
input[type='button'] {
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
outline: 2px solid transparent;
}

1
packages/ui/AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -83,6 +83,7 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lru-cache": "^11.2.4", "lru-cache": "^11.2.4",
"markdown-it": "^13.0.2", "markdown-it": "^13.0.2",
"motion-v": "^2.2.1",
"postprocessing": "^6.37.6", "postprocessing": "^6.37.6",
"qrcode.vue": "^3.4.1", "qrcode.vue": "^3.4.1",
"three": "^0.172.0", "three": "^0.172.0",

View File

@@ -1,72 +1,95 @@
<template> <template>
<div <div
:class="[ :class="[
'relative flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast', 'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
progress != null ? 'overflow-hidden pb-5' : '',
typeClasses[type], typeClasses[type],
]" ]"
> >
<div class="flex items-start gap-2"> <slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
</slot>
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
<div <div
:class="[ v-if="header || $slots.header || normalizedTimestamp"
'flex flex-1 gap-2', class="flex flex-wrap items-center gap-2 text-lg font-bold leading-6"
header || $slots.header ? 'flex-col items-start' : 'items-center',
(dismissible || $slots['top-right-actions']) && 'pr-8',
]"
> >
<div <slot name="header">{{ header }}</slot>
class="flex gap-2 items-start" <span
:class="header || $slots.header ? 'w-full' : 'contents'" v-if="normalizedTimestamp"
v-tooltip="timestampTooltip"
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
> >
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]"> <ClockIcon class="size-4" />
<component {{ relativeTimeLabel }}
:is="getSeverityIcon(type)" </span>
:class="['h-6 w-6 flex-none', iconClasses[type]]"
/>
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
</div>
</div>
<div class="font-normal text-contrast/80" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot>
</div>
</div> </div>
<div v-if="$slots['top-right-actions']" class="flex shrink-0 items-center gap-2"> <div class="font-normal text-contrast/85">
<slot name="top-right-actions" /> <slot>{{ body }}</slot>
</div> </div>
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
<slot name="actions" />
</div>
</div>
<div
v-if="$slots['top-right-actions'] || dismissible"
class="col-start-3 row-start-1 flex shrink-0 items-center gap-2 self-start"
>
<slot name="top-right-actions" />
<ButtonStyled <ButtonStyled
v-else-if="dismissible" v-if="dismissible"
circular circular
type="highlight-colored-text" type="transparent"
:color="buttonColors[type]" :color="buttonColors[type]"
hover-color-fill="background"
> >
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')"> <button type="button" aria-label="Dismiss" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" /> <XIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div v-if="$slots.progress"> <div
<slot name="progress" /> v-if="progress != null"
</div> class="absolute inset-x-0 bottom-0 h-1 overflow-hidden"
<div v-if="showActionsUnderneath || $slots.actions"> :class="progressTrackClasses[type]"
<slot name="actions" /> role="progressbar"
:aria-valuenow="waiting ? undefined : Math.round(normalizedProgress * 100)"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full rounded-r-full transition-[width] duration-200 ease-in-out"
:class="[
progressFillClasses[progressColor ?? type],
{ 'admonition-progress--waiting': waiting },
]"
:style="waiting ? undefined : { width: `${normalizedProgress * 100}%` }"
/>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { XIcon } from '@modrinth/assets' import { ClockIcon, XIcon } from '@modrinth/assets'
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../composables'
import { getSeverityIcon } from '../../utils' import { getSeverityIcon } from '../../utils'
import ButtonStyled from './ButtonStyled.vue' import ButtonStyled from './ButtonStyled.vue'
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success' type?: 'info' | 'warning' | 'critical' | 'success'
header?: string header?: string
body?: string body?: string
showActionsUnderneath?: boolean showActionsUnderneath?: boolean
dismissible?: boolean dismissible?: boolean
progress?: number
progressColor?: 'info' | 'warning' | 'critical' | 'success' | 'blue' | 'green' | 'red'
waiting?: boolean
/** Accepts a Date, an ISO string, or a millisecond Unix timestamp. */
timestamp?: Date | string | number
}>(), }>(),
{ {
type: 'info', type: 'info',
@@ -74,6 +97,10 @@ withDefaults(
body: '', body: '',
showActionsUnderneath: false, showActionsUnderneath: false,
dismissible: false, dismissible: false,
progress: undefined,
progressColor: undefined,
waiting: false,
timestamp: undefined,
}, },
) )
@@ -81,6 +108,34 @@ defineEmits<{
dismiss: [] dismiss: []
}>() }>()
const relativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
dateStyle: 'long',
timeStyle: 'short',
})
const now = useNow({ interval: 1000 })
const normalizedProgress = computed(() => Math.min(Math.max(props.progress ?? 0, 0), 1))
const normalizedTimestamp = computed(() => {
const t = props.timestamp
if (t == null) return null
if (t instanceof Date) return t.toISOString()
if (typeof t === 'number') return new Date(t).toISOString()
return t
})
const relativeTimeLabel = computed(() => {
void now.value
const t = normalizedTimestamp.value
return t ? relativeTime(t) : ''
})
const timestampTooltip = computed(() => {
const t = normalizedTimestamp.value
return t ? formatDateTime(t) : ''
})
const typeClasses = { const typeClasses = {
info: 'border-brand-blue bg-bg-blue', info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange', warning: 'border-brand-orange bg-bg-orange',
@@ -95,10 +150,45 @@ const iconClasses = {
success: 'text-brand-green', success: 'text-brand-green',
} }
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = { const buttonColors = {
info: 'blue', info: 'blue',
warning: 'orange', warning: 'orange',
critical: 'red', critical: 'red',
success: 'green', success: 'green',
} as const
const progressTrackClasses = {
info: 'bg-brand-blue/20',
warning: 'bg-brand-orange/20',
critical: 'bg-brand-red/20',
success: 'bg-brand-green/20',
}
const progressFillClasses = {
info: 'bg-brand-blue',
warning: 'bg-brand-orange',
critical: 'bg-brand-red',
success: 'bg-brand-green',
blue: 'bg-brand-blue',
green: 'bg-brand-green',
red: 'bg-brand-red',
} }
</script> </script>
<style scoped>
.admonition-progress--waiting {
animation: admonition-progress-waiting 1s linear infinite;
position: relative;
width: 20%;
}
@keyframes admonition-progress-waiting {
0% {
left: -20%;
}
100% {
left: 100%;
}
}
</style>

View File

@@ -4,6 +4,13 @@
> >
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1"> <div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
<div ref="containerRef" class="size-full" /> <div ref="containerRef" class="size-full" />
<Transition name="terminal-loading-fade">
<div
v-if="loading"
class="pointer-events-none absolute inset-0 z-20 animate-bpulse bg-surface-3"
aria-hidden="true"
/>
</Transition>
<div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10"> <div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10">
<ButtonStyled circular type="highlight" size="large"> <ButtonStyled circular type="highlight" size="large">
<button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom"> <button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom">
@@ -46,6 +53,7 @@ const props = withDefaults(
disableInput?: boolean disableInput?: boolean
fullscreen?: boolean fullscreen?: boolean
emptyStateType?: 'server' | 'instance' emptyStateType?: 'server' | 'instance'
loading?: boolean
}>(), }>(),
{ {
scrollback: Infinity, scrollback: Infinity,
@@ -53,6 +61,7 @@ const props = withDefaults(
disableInput: false, disableInput: false,
fullscreen: false, fullscreen: false,
emptyStateType: undefined, emptyStateType: undefined,
loading: false,
}, },
) )
@@ -230,6 +239,15 @@ defineExpose({
</script> </script>
<style> <style>
@keyframes bpulse {
50% {
filter: brightness(75%);
}
}
.animate-bpulse {
animation: bpulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.xterm { .xterm {
height: 100% !important; height: 100% !important;
} }
@@ -269,4 +287,14 @@ defineExpose({
border-radius: 8px !important; border-radius: 8px !important;
contain: layout style !important; contain: layout style !important;
} }
.terminal-loading-fade-enter-active,
.terminal-loading-fade-leave-active {
transition: opacity 250ms ease-in-out;
}
.terminal-loading-fade-enter-from,
.terminal-loading-fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -7,8 +7,8 @@
? 'cursor-not-allowed opacity-50' ? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]' : 'cursor-pointer hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]'
" "
:aria-label="description || label" :aria-label="description || label || undefined"
:aria-checked="modelValue" :aria-checked="indeterminate ? 'mixed' : modelValue"
role="checkbox" role="checkbox"
@click="toggle" @click="toggle"
> >
@@ -25,7 +25,7 @@
<CheckIcon v-else-if="modelValue" aria-hidden="true" stroke-width="3" /> <CheckIcon v-else-if="modelValue" aria-hidden="true" stroke-width="3" />
</span> </span>
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label --> <!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
<span v-if="label" aria-hidden="true"> <span v-if="label" :class="labelClass" aria-hidden="true">
{{ label }} {{ label }}
</span> </span>
<slot v-else /> <slot v-else />
@@ -33,6 +33,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CheckIcon, MinusIcon } from '@modrinth/assets' import { CheckIcon, MinusIcon } from '@modrinth/assets'
import type { HTMLAttributes } from 'vue'
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [boolean] 'update:modelValue': [boolean]
@@ -41,6 +42,7 @@ const emit = defineEmits<{
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
label?: string label?: string
labelClass?: HTMLAttributes['class']
disabled?: boolean disabled?: boolean
description?: string description?: string
modelValue: boolean modelValue: boolean
@@ -49,6 +51,7 @@ const props = withDefaults(
}>(), }>(),
{ {
label: '', label: '',
labelClass: '',
disabled: false, disabled: false,
description: '', description: '',
modelValue: false, modelValue: false,

View File

@@ -12,7 +12,7 @@ const toolbarEl = ref<HTMLElement | null>(null)
const compact = ref(false) const compact = ref(false)
const { stackCount } = useModalStack() const { stackCount } = useModalStack()
const zIndex = computed(() => 100 + stackCount.value * 10 + 31) const zIndex = computed(() => 100 + stackCount.value * 10 + 8)
function checkCompact() { function checkCompact() {
const el = toolbarEl.value const el = toolbarEl.value

View File

@@ -0,0 +1,617 @@
<script lang="ts"></script>
<script setup lang="ts" generic="ItemType extends StackedAdmonitionItem">
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
import { AnimatePresence, Motion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import ButtonStyled from './ButtonStyled.vue'
export type StackedAdmonitionType = 'info' | 'warning' | 'critical' | 'success'
/** Extend this interface to attach arbitrary per-item data consumed in the #item slot. */
export interface StackedAdmonitionItem {
id: string
type: StackedAdmonitionType
dismissible?: boolean
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<{
items: ItemType[]
peek?: number
hoverPeek?: number
expandedGap?: number
scaleStep?: number
hoverScaleStep?: number
maxVisibleBehind?: number
dismissAllEnabled?: boolean
expanded?: boolean
}>(),
{
peek: 8,
hoverPeek: 16,
expandedGap: 12,
scaleStep: 0.04,
hoverScaleStep: 0.025,
maxVisibleBehind: 2,
dismissAllEnabled: true,
expanded: undefined,
},
)
const emit = defineEmits<{
'dismiss-all': []
'update:expanded': [value: boolean]
expand: []
collapse: []
}>()
defineSlots<{
item(props: {
item: ItemType
index: number
isFront: boolean
expanded: boolean
/** Whether the consumer should render the Admonition's own dismiss button. */
dismissible: boolean
}): unknown
'header-label'(props: { count: number; expanded: boolean }): unknown
}>()
const { formatMessage } = useVIntl()
const stackId = useId()
const attrs = useAttrs()
const internalExpanded = ref(false)
const isHovered = ref(false)
const prefersReducedMotion = ref(false)
const initialMeasurementSettled = ref(false)
const enteringItemIds = ref<Set<string>>(new Set())
const actionBarHeight = ref(0)
const heights = ref<Record<string, number>>({})
const cardEls = new Map<string, HTMLElement>()
const observers = new Map<string, ResizeObserver>()
const pendingHeights = new Map<string, number>()
let flushHandle: number | null = null
let initialMeasurementHandle: number | null = null
let enteringHandle: number | null = null
let actionBarObserver: ResizeObserver | null = null
// Slot content may run effects, so measure the one real tree instead of mounting
// hidden duplicates just to discover natural card heights.
function scheduleHeightFlush() {
if (flushHandle != null) return
flushHandle = requestAnimationFrame(() => {
flushHandle = null
if (pendingHeights.size === 0) return
const next = { ...heights.value }
let changed = false
for (const [id, h] of pendingHeights) {
if (next[id] !== h) {
next[id] = h
changed = true
}
}
pendingHeights.clear()
if (changed) {
heights.value = next
if (!initialMeasurementSettled.value && initialMeasurementHandle == null) {
initialMeasurementHandle = requestAnimationFrame(() => {
initialMeasurementHandle = null
initialMeasurementSettled.value = true
})
}
}
})
}
const isExpanded = computed(() => {
if (props.items.length <= 1) return false
return props.expanded ?? internalExpanded.value
})
const hasActionBar = computed(() => props.items.length >= 2)
function itemDismissible(item: ItemType) {
return item.dismissible ?? true
}
type StackPhase = 'collapsed' | 'expanding' | 'expanded' | 'collapsing'
const phase = ref<StackPhase>(isExpanded.value ? 'expanded' : 'collapsed')
const isSettledCollapsed = computed(() => phase.value === 'collapsed')
const containerHeightSettled = ref(true)
const singleItemEntrance = ref(false)
// Behind cards morph between a collapsed placeholder and real content. The shell
// height owns that morph so mixed-height cards do not swap DOM midway through motion.
function measuredCardHeight(index: number) {
const item = props.items[index]
return item ? (heights.value[item.id] ?? 0) : 0
}
function hasMeasuredCard(index: number) {
const item = props.items[index]
return !!item && heights.value[item.id] != null
}
const frontCardHeight = computed(() => measuredCardHeight(0))
const hasBehind = computed(() => props.items.length > 1)
function currentPeek() {
return isHovered.value ? props.hoverPeek : props.peek
}
function currentScaleStep() {
return isHovered.value ? props.hoverScaleStep : props.scaleStep
}
function targetCardHeight(index: number) {
if (index === 0) return measuredCardHeight(0)
const measured = measuredCardHeight(index) || frontCardHeight.value
return isExpanded.value ? measured : frontCardHeight.value
}
const containerHeight = computed(() => {
if (isExpanded.value) {
return props.items.reduce((acc, _, i) => {
return acc + measuredCardHeight(i) + (i > 0 ? props.expandedGap : 0)
}, 0)
}
if (!hasBehind.value) return frontCardHeight.value
const behind = Math.min(props.items.length - 1, props.maxVisibleBehind)
const pad = isHovered.value ? 6 : 0
return frontCardHeight.value + currentPeek() * behind + pad
})
const stackShellHeight = computed(() => {
return containerHeight.value + (hasActionBar.value ? actionBarHeight.value : 0)
})
const containerOverflow = computed(() => {
if (isExpanded.value) return 'visible'
if (!containerHeightSettled.value) return 'hidden'
if (!hasBehind.value && hasMeasuredCard(0)) return 'visible'
return 'hidden'
})
const springTransition = computed(() =>
prefersReducedMotion.value || !initialMeasurementSettled.value
? { duration: 0 }
: { type: 'spring' as const, stiffness: 260, damping: 32 },
)
const heightTransition = computed(() =>
singleItemEntrance.value ? { duration: 0.12, ease: 'easeOut' as const } : springTransition.value,
)
const exitTransition = computed(() =>
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.18 },
)
const shellExitTransition = computed(() =>
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.16 },
)
function collapsedCardPosition(index: number) {
const hidden = index > props.maxVisibleBehind
return {
y: index * currentPeek(),
scale: Math.max(0.8, 1 - index * currentScaleStep()),
opacity: hidden ? 0 : 1,
}
}
function expandedCardPosition(index: number) {
let y = 0
for (let i = 0; i < index; i++) {
y += measuredCardHeight(i) + props.expandedGap
}
return { y, scale: 1, opacity: 1 }
}
function cardPosition(index: number) {
const position = isExpanded.value ? expandedCardPosition(index) : collapsedCardPosition(index)
const item = props.items[index]
if (index === 0 && singleItemEntrance.value) {
return {
...position,
opacity: 0,
}
}
if (!item || !enteringItemIds.value.has(item.id)) return position
return {
...position,
y: position.y + 8,
opacity: 0,
scale: Math.min(1, position.scale + 0.02),
}
}
function contentOpacity(index: number) {
return isExpanded.value && hasMeasuredCard(index) ? 1 : 0
}
// Newly inserted cards need an explicit two-frame enter target because Motion's
// initial state is disabled to avoid animating from zero-height on first mount.
function markEntering(ids: string[]) {
if (!initialMeasurementSettled.value || prefersReducedMotion.value || ids.length === 0) return
const next = new Set(enteringItemIds.value)
for (const id of ids) next.add(id)
enteringItemIds.value = next
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
enteringHandle = requestAnimationFrame(() => {
enteringHandle = requestAnimationFrame(() => {
enteringHandle = null
enteringItemIds.value = new Set()
})
})
}
function onContainerAnimationComplete() {
phase.value = isExpanded.value ? 'expanded' : 'collapsed'
containerHeightSettled.value = true
if (containerHeight.value > 0) {
singleItemEntrance.value = false
}
}
const containerMotionProps = computed(() => ({
onAnimationComplete: onContainerAnimationComplete,
}))
function resolveNode(el: unknown): HTMLElement | null {
if (!el) return null
if (el instanceof HTMLElement) return el
if (typeof el === 'object' && '$el' in el) {
const node = (el as { $el: unknown }).$el
return node instanceof HTMLElement ? node : null
}
return null
}
function setCardRef(id: string, el: unknown) {
const node = resolveNode(el)
if (!node) return
pendingHeights.set(id, node.offsetHeight)
scheduleHeightFlush()
if (cardEls.get(id) === node) return
observers.get(id)?.disconnect()
cardEls.set(id, node)
const ro = new ResizeObserver(() => {
pendingHeights.set(id, node.offsetHeight)
scheduleHeightFlush()
})
ro.observe(node)
observers.set(id, ro)
}
function setActionBarRef(el: unknown) {
const node = resolveNode(el)
actionBarObserver?.disconnect()
actionBarObserver = null
if (!node) {
actionBarHeight.value = 0
return
}
actionBarHeight.value = node.offsetHeight
const ro = new ResizeObserver(() => {
actionBarHeight.value = node.offsetHeight
})
ro.observe(node)
actionBarObserver = ro
}
function setExpanded(v: boolean) {
internalExpanded.value = v
emit('update:expanded', v)
if (v) emit('expand')
else emit('collapse')
}
function openStack() {
if (props.items.length <= 1 || isExpanded.value) return
phase.value = 'expanding'
setExpanded(true)
}
function closeStack() {
if (!isExpanded.value) return
phase.value = 'collapsing'
setExpanded(false)
}
function toggleExpanded() {
if (props.items.length <= 1) return
if (isExpanded.value) closeStack()
else openStack()
}
function isInteractiveTarget(target: HTMLElement | null, currentTarget: EventTarget | null) {
if (!target) return false
const interactive = target.closest(
'button, a, input, select, textarea, summary, [role="button"], [role="link"]',
)
return !!interactive && interactive !== currentTarget
}
function onContainerClick(e: MouseEvent) {
if (isExpanded.value || props.items.length <= 1) return
const target = e.target as HTMLElement | null
if (isInteractiveTarget(target, e.currentTarget)) return
openStack()
}
function onCardClick(e: MouseEvent) {
if (!isExpanded.value) return
const target = e.target as HTMLElement | null
if (isInteractiveTarget(target, e.currentTarget)) return
e.stopPropagation()
closeStack()
}
watch(
() => props.items.length,
(n, previousLength) => {
if (previousLength === 0 && n === 1 && !prefersReducedMotion.value) {
singleItemEntrance.value = true
} else if (n !== 1) {
singleItemEntrance.value = false
}
if (n <= 1 && (props.expanded ?? internalExpanded.value)) {
phase.value = 'collapsed'
setExpanded(false)
}
},
)
watch(isExpanded, (expanded, previousExpanded) => {
if (previousExpanded === undefined) {
phase.value = expanded ? 'expanded' : 'collapsed'
return
}
if (expanded && phase.value !== 'expanding') phase.value = 'expanding'
else if (!expanded && phase.value !== 'collapsing') phase.value = 'collapsing'
})
watch(containerHeight, (height, previousHeight) => {
if (height !== previousHeight) {
const openingSingleItem =
previousHeight === 0 && height > 0 && props.items.length === 1 && !prefersReducedMotion.value
if (openingSingleItem) {
singleItemEntrance.value = true
} else if (height === 0 || props.items.length !== 1) {
singleItemEntrance.value = false
}
containerHeightSettled.value =
prefersReducedMotion.value || (!initialMeasurementSettled.value && !openingSingleItem)
}
})
watch(
() => props.items.map((i) => i.id),
(ids, previousIds = []) => {
const idSet = new Set(ids)
const previousIdSet = new Set(previousIds)
markEntering(ids.filter((id) => !previousIdSet.has(id)))
for (const [id, ro] of observers) {
if (!idSet.has(id)) {
ro.disconnect()
observers.delete(id)
cardEls.delete(id)
}
}
const next: Record<string, number> = {}
for (const id of idSet) {
if (heights.value[id] != null) next[id] = heights.value[id]
}
heights.value = next
},
)
let mql: MediaQueryList | null = null
function syncRM(e: MediaQueryListEvent | MediaQueryList) {
prefersReducedMotion.value = 'matches' in e ? e.matches : false
}
onMounted(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
mql = window.matchMedia('(prefers-reduced-motion: reduce)')
prefersReducedMotion.value = mql.matches
mql.addEventListener('change', syncRM)
})
onBeforeUnmount(() => {
mql?.removeEventListener('change', syncRM)
for (const ro of observers.values()) ro.disconnect()
actionBarObserver?.disconnect()
observers.clear()
cardEls.clear()
pendingHeights.clear()
if (flushHandle != null) cancelAnimationFrame(flushHandle)
if (initialMeasurementHandle != null) cancelAnimationFrame(initialMeasurementHandle)
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
})
const placeholderClasses: Record<StackedAdmonitionType, string> = {
info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
}
const messages = defineMessages({
alertCount: {
id: 'ui.stacked-admonitions.alert-count',
defaultMessage: '{count, plural, one {# alert} other {# alerts}}',
},
dismissAll: {
id: 'ui.stacked-admonitions.dismiss-all',
defaultMessage: 'Dismiss all',
},
})
</script>
<template>
<AnimatePresence :initial="false">
<Motion
v-if="items.length > 0"
v-bind="attrs"
as="div"
class="relative"
:initial="false"
:animate="{ height: stackShellHeight, opacity: 1, y: 0 }"
:exit="{
height: 0,
opacity: 0,
overflow: 'hidden',
y: -4,
transition: shellExitTransition,
}"
:transition="heightTransition"
>
<Transition
enter-active-class="overflow-hidden transition-all duration-150 ease-out"
enter-from-class="-translate-y-1 opacity-0 max-h-0"
enter-to-class="translate-y-0 opacity-100 max-h-14"
leave-active-class="overflow-hidden transition-all duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100 max-h-14"
leave-to-class="-translate-y-1 opacity-0 max-h-0"
>
<div v-if="hasActionBar" :ref="(el: unknown) => setActionBarRef(el)">
<div class="flex items-center justify-between pb-2">
<ButtonStyled type="transparent">
<button
type="button"
:aria-expanded="isExpanded"
:aria-controls="stackId"
@click="toggleExpanded"
>
<Motion
as="span"
class="inline-flex"
:animate="{ rotate: isExpanded ? 0 : -90 }"
:transition="{ type: 'spring', stiffness: 350, damping: 30 }"
>
<ChevronDownIcon class="h-4 w-4" />
</Motion>
<slot name="header-label" :count="items.length" :expanded="isExpanded">
{{ formatMessage(messages.alertCount, { count: items.length }) }}
</slot>
</button>
</ButtonStyled>
<ButtonStyled v-if="dismissAllEnabled" type="transparent">
<button type="button" @click="$emit('dismiss-all')">
<XIcon class="h-4 w-4" />
{{ formatMessage(messages.dismissAll) }}
</button>
</ButtonStyled>
</div>
</div>
</Transition>
<!-- Expanded-target overflow must become visible immediately so cards added
during the tail of the expand spring do not inherit collapse clipping. -->
<Motion
:id="stackId"
as="div"
class="relative"
:initial="false"
:animate="{ height: containerHeight }"
:transition="heightTransition"
:style="{ overflow: containerOverflow }"
v-bind="containerMotionProps"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@click="onContainerClick"
>
<AnimatePresence :initial="false">
<Motion
v-for="(item, index) in items"
:key="item.id"
as="div"
class="absolute inset-x-0 top-0 rounded-2xl bg-bg will-change-transform"
:initial="false"
:animate="cardPosition(index)"
:exit="{ opacity: 0, scale: 0.9, transition: exitTransition }"
:transition="springTransition"
:style="{
zIndex: items.length - index,
transformOrigin: 'top center',
}"
:aria-hidden="isSettledCollapsed && index !== 0 ? 'true' : undefined"
@click="onCardClick"
>
<template v-if="index === 0">
<div :ref="(el: unknown) => setCardRef(item.id, el)">
<slot
name="item"
:item="item"
:index="index"
:is-front="true"
:expanded="isExpanded"
:dismissible="itemDismissible(item)"
/>
</div>
</template>
<template v-else>
<div class="relative">
<Motion
as="div"
:class="[
'absolute inset-0 rounded-2xl border border-solid',
placeholderClasses[item.type],
]"
:initial="false"
:animate="{ opacity: isExpanded ? 0 : 1 }"
:transition="springTransition"
aria-hidden="true"
/>
<Motion
as="div"
:initial="false"
:animate="{ height: targetCardHeight(index) }"
:transition="springTransition"
:style="{ overflow: isExpanded ? 'visible' : 'hidden' }"
>
<Motion
as="div"
:initial="false"
:animate="{ opacity: contentOpacity(index) }"
:transition="springTransition"
>
<div
:ref="(el: unknown) => setCardRef(item.id, el)"
:inert="!isExpanded ? true : undefined"
>
<slot
name="item"
:item="item"
:index="index"
:is-front="false"
:expanded="isExpanded"
:dismissible="itemDismissible(item)"
/>
</div>
</Motion>
</Motion>
</div>
</template>
</Motion>
</AnimatePresence>
</Motion>
</Motion>
</AnimatePresence>
</template>

View File

@@ -70,6 +70,8 @@ export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SimpleBadge } from './SimpleBadge.vue' export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue' export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue' export { default as SmartClickable } from './SmartClickable.vue'
export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmonitions.vue'
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
export { default as StatItem } from './StatItem.vue' export { default as StatItem } from './StatItem.vue'
export { default as StyledInput } from './StyledInput.vue' export { default as StyledInput } from './StyledInput.vue'
export type { TableColumn } from './Table.vue' export type { TableColumn } from './Table.vue'

View File

@@ -1,12 +1,19 @@
<template> <template>
<Admonition :type="contentError ? 'critical' : 'info'" :show-actions-underneath="!contentError"> <Admonition
:type="contentError ? 'critical' : 'info'"
:dismissible="dismissible"
:progress="progressValue"
progress-color="blue"
:waiting="isWaiting"
@dismiss="emit('dismiss')"
>
<template #icon> <template #icon>
<slot v-if="!contentError" name="icon"> <slot v-if="!contentError" name="icon">
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" /> <SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
</slot> </slot>
</template> </template>
<template #header> <template #header>
{{ contentError ? 'Installation error' : "We're preparing your server!" }} {{ contentError ? 'Installation failed' : "We're preparing your server" }}
</template> </template>
<template v-if="contentError"> <template v-if="contentError">
{{ errorLabel }} {{ errorLabel }}
@@ -26,22 +33,12 @@
</div> </div>
<template v-if="contentError" #top-right-actions> <template v-if="contentError" #top-right-actions>
<ButtonStyled color="red" type="outlined"> <ButtonStyled color="red" type="outlined">
<button class="!border" @click="emit('retry')"> <button class="!border" type="button" @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" /> <RotateCounterClockwiseIcon class="size-5" />
Retry Retry
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
<template v-if="!contentError" #actions>
<ProgressBar
v-if="progress"
:progress="progress.percent"
:max="100"
color="blue"
full-width
/>
<ProgressBar v-else :progress="0" :max="1" color="blue" full-width waiting />
</template>
</Admonition> </Admonition>
</template> </template>
@@ -52,7 +49,6 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import Admonition from '../base/Admonition.vue' import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue' import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
export interface SyncProgress { export interface SyncProgress {
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons' phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
@@ -67,10 +63,12 @@ export interface ContentError {
const props = defineProps<{ const props = defineProps<{
progress?: SyncProgress | null progress?: SyncProgress | null
contentError?: ContentError | null contentError?: ContentError | null
dismissible?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
retry: [] retry: []
dismiss: []
}>() }>()
const errorLabel = computed(() => { const errorLabel = computed(() => {
@@ -91,10 +89,10 @@ const errorLabel = computed(() => {
if (step === 'modpack') { if (step === 'modpack') {
if (desc?.includes('no primary file')) { if (desc?.includes('no primary file')) {
return 'The modpack version has no downloadable file. It may have been packaged incorrectly.' return 'This modpack version does not include a downloadable file. It may have been packaged incorrectly.'
} }
if (desc?.includes('failed to install')) { if (desc?.includes('failed to install')) {
return 'Failed to install the modpack. It may be corrupted or incompatible.' return 'The modpack could not be installed. It may be corrupted or incompatible.'
} }
} }
@@ -114,6 +112,16 @@ const phaseLabel = computed(() => {
} }
}) })
const progressValue = computed(() => {
if (props.contentError) return undefined
return props.progress ? props.progress.percent / 100 : 0
})
const isWaiting = computed(() => {
if (props.contentError) return false
return !props.progress || props.progress.percent <= 0
})
const tickerMessages = [ const tickerMessages = [
'Organizing files...', 'Organizing files...',
'Downloading mods...', 'Downloading mods...',

View File

@@ -0,0 +1,293 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import type { MessageDescriptor } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
export type AdmonitionDisplayState = 'ongoing' | Archon.BackupsQueue.v1.BackupQueueState
export type BackupAdmonitionEntry = {
key: string
backupId: string
type: 'create' | 'restore'
state: AdmonitionDisplayState
progress: number
operationId: number | null
syntheticLegacy: boolean
name?: string
timestamp?: string
error?: string | null
}
defineProps<{
item: BackupAdmonitionEntry
dismissible: boolean
cancelling: boolean
}>()
defineEmits<{
dismiss: []
retry: []
cancel: []
}>()
const { formatMessage } = useVIntl()
type UiPhase = 'queued' | 'in_progress' | 'failed' | 'timed_out' | 'cancelled' | 'completed'
function resolveUiPhase(item: BackupAdmonitionEntry): UiPhase | null {
switch (item.state) {
case 'pending':
return 'queued'
case 'ongoing':
return 'in_progress'
case 'failed':
case 'timed_out':
case 'cancelled':
case 'completed':
return item.state
default:
return null
}
}
function getAdmonitionType(state: AdmonitionDisplayState): 'info' | 'critical' | 'success' {
if (state === 'failed' || state === 'timed_out') return 'critical'
if (state === 'completed') return 'success'
return 'info'
}
function getIcon(state: AdmonitionDisplayState) {
if (state === 'failed' || state === 'timed_out') return TriangleAlertIcon
if (state === 'completed') return CheckCircleIcon
return InfoIcon
}
function isQueued(item: BackupAdmonitionEntry) {
return resolveUiPhase(item) === 'queued'
}
function isInProgress(item: BackupAdmonitionEntry) {
return resolveUiPhase(item) === 'in_progress'
}
function isTerminal(item: BackupAdmonitionEntry) {
return item.state !== 'pending' && item.state !== 'ongoing'
}
function canRetry(item: BackupAdmonitionEntry) {
return item.state === 'failed' || item.state === 'timed_out'
}
function canCancel(item: BackupAdmonitionEntry) {
return isQueued(item) || isInProgress(item)
}
function hasErrorDetail(item: BackupAdmonitionEntry) {
return !!item.error && (item.state === 'failed' || item.state === 'timed_out')
}
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
backupTimedOutTitle: {
id: 'servers.backups.admonition.backup-timed-out.title',
defaultMessage: 'Backup timed out',
},
backupTimedOutDescription: {
id: 'servers.backups.admonition.backup-timed-out.description',
defaultMessage:
'Creating {backupName} timed out. You can try again or contact support if the issue continues.',
},
backupCancelledTitle: {
id: 'servers.backups.admonition.backup-cancelled.title',
defaultMessage: 'Backup cancelled',
},
backupCancelledDescription: {
id: 'servers.backups.admonition.backup-cancelled.description',
defaultMessage: 'Backup {backupName} was cancelled.',
},
backupCompletedTitle: {
id: 'servers.backups.admonition.backup-completed.title',
defaultMessage: 'Backup finished',
},
backupCompletedDescription: {
id: 'servers.backups.admonition.backup-completed.description',
defaultMessage: '{backupName} finished successfully.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restore queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restore finished',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restore failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
restoreTimedOutTitle: {
id: 'servers.backups.admonition.restore-timed-out.title',
defaultMessage: 'Restore timed out',
},
restoreTimedOutDescription: {
id: 'servers.backups.admonition.restore-timed-out.description',
defaultMessage:
'Restoring from {backupName} timed out. You can try again or contact support if the issue continues.',
},
restoreCancelledTitle: {
id: 'servers.backups.admonition.restore-cancelled.title',
defaultMessage: 'Restore cancelled',
},
restoreCancelledDescription: {
id: 'servers.backups.admonition.restore-cancelled.description',
defaultMessage: 'Restoring from {backupName} was cancelled.',
},
})
const createTitles: Record<UiPhase, MessageDescriptor> = {
queued: messages.backupQueuedTitle,
in_progress: messages.creatingBackupTitle,
failed: messages.backupFailedTitle,
timed_out: messages.backupTimedOutTitle,
cancelled: messages.backupCancelledTitle,
completed: messages.backupCompletedTitle,
}
const restoreTitles: Record<UiPhase, MessageDescriptor> = {
queued: messages.restoreQueuedTitle,
in_progress: messages.restoringBackupTitle,
failed: messages.restoreFailedTitle,
timed_out: messages.restoreTimedOutTitle,
cancelled: messages.restoreCancelledTitle,
completed: messages.restoreSuccessfulTitle,
}
const createDescriptions: Record<UiPhase, MessageDescriptor> = {
queued: messages.backupQueuedDescription,
in_progress: messages.creatingBackupDescription,
failed: messages.backupFailedDescription,
timed_out: messages.backupTimedOutDescription,
cancelled: messages.backupCancelledDescription,
completed: messages.backupCompletedDescription,
}
const restoreDescriptions: Record<UiPhase, MessageDescriptor> = {
queued: messages.restoreQueuedDescription,
in_progress: messages.restoringBackupDescription,
failed: messages.restoreFailedDescription,
timed_out: messages.restoreTimedOutDescription,
cancelled: messages.restoreCancelledDescription,
completed: messages.restoreSuccessfulDescription,
}
function getTitle(item: BackupAdmonitionEntry): string {
const phase = resolveUiPhase(item)
if (phase == null) return ''
const table = item.type === 'create' ? createTitles : restoreTitles
return formatMessage(table[phase])
}
function getDescription(item: BackupAdmonitionEntry): string {
const phase = resolveUiPhase(item)
if (phase == null) return ''
const table = item.type === 'create' ? createDescriptions : restoreDescriptions
const backupName = item.name ?? formatMessage(messages.fallbackName)
return formatMessage(table[phase], { backupName })
}
</script>
<template>
<Admonition
:type="getAdmonitionType(item.state)"
:header="getTitle(item)"
:timestamp="item.timestamp"
:dismissible="dismissible && isTerminal(item)"
:progress="isInProgress(item) ? item.progress : undefined"
progress-color="blue"
:waiting="isInProgress(item) && item.progress === 0"
@dismiss="$emit('dismiss')"
>
<template #icon="{ iconClass }">
<component :is="getIcon(item.state)" :class="iconClass" />
</template>
<div class="flex flex-col gap-2">
<span>{{ getDescription(item) }}</span>
<span v-if="hasErrorDetail(item)" class="break-all font-mono text-sm text-secondary">
{{ item.error }}
</span>
</div>
<template #top-right-actions>
<ButtonStyled v-if="canCancel(item)" type="outlined" color="blue">
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="canRetry(item)" color="red" type="outlined">
<button class="!border" type="button" @click="$emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>

View File

@@ -0,0 +1,99 @@
<template>
<Admonition
:type="op.state === 'done' ? 'success' : op.state?.startsWith('fail') ? 'critical' : 'info'"
:dismissible="dismissible && isTerminal"
:progress="'progress' in op ? (op.progress ?? 0) : 0"
:progress-color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
@dismiss="$emit('dismiss')"
>
<template #icon="{ iconClass }">
<PackageOpenIcon :class="iconClass" />
</template>
<template #header>{{ title }}</template>
<span class="text-secondary">
<span>
{{
formatMessage(messages.extracted, {
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
})
}}
</span>
<span v-if="'current_file' in op && op.current_file">
. {{ formatMessage(messages.currentFile, { file: op.current_file?.split('/')?.pop() }) }}
</span>
</span>
<template v-if="op.id" #top-right-actions>
<ButtonStyled v-if="!isTerminal" type="outlined" color="blue">
<button class="!border" type="button" @click="ctx.dismissOperation(op.id!, 'cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { PackageOpenIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
defineEmits<{ dismiss: [] }>()
const props = defineProps<{
op: FileOperation
dismissible: boolean
}>()
const { formatMessage } = useVIntl()
const ctx = injectModrinthServerContext()
const messages = defineMessages({
extracting: {
id: 'files.operations.extracting',
defaultMessage: 'Extracting {source}',
},
extractingCompleted: {
id: 'files.operations.extracting-completed',
defaultMessage: 'Extracting {source} finished',
},
extractingFailed: {
id: 'files.operations.extracting-failed',
defaultMessage: 'Extracting {source} failed',
},
modpackFromUrl: {
id: 'files.operations.modpack-from-url',
defaultMessage: 'modpack from URL',
},
extracted: {
id: 'files.operations.extracted',
defaultMessage: '{size} extracted',
},
currentFile: {
id: 'files.operations.current-file',
defaultMessage: 'Current file: {file}',
},
})
const isTerminal = computed(() => props.op.state === 'done' || !!props.op.state?.startsWith('fail'))
const sourceName = computed(() =>
props.op.src.includes('https://') ? formatMessage(messages.modpackFromUrl) : props.op.src,
)
const title = computed(() => {
if (props.op.state === 'done') {
return formatMessage(messages.extractingCompleted, { source: sourceName.value })
}
if (props.op.state?.startsWith('fail')) {
return formatMessage(messages.extractingFailed, { source: sourceName.value })
}
return formatMessage(messages.extracting, { source: sourceName.value })
})
</script>

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import StackedAdmonitions, {
type StackedAdmonitionItem,
} from '#ui/components/base/StackedAdmonitions.vue'
import { ServerIcon } from '#ui/components/servers/icons'
import InstallingBanner, {
type ContentError,
type SyncProgress,
} from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
import BackupAdmonition, { type BackupAdmonitionEntry } from './BackupAdmonition.vue'
import FileOperationAdmonition from './FileOperationAdmonition.vue'
import UploadAdmonition from './UploadAdmonition.vue'
const props = defineProps<{
syncProgress?: SyncProgress | null
contentError?: ContentError | null
serverImage?: string
}>()
const emit = defineEmits<{
'content-retry': []
}>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const ctx = injectModrinthServerContext()
const route = useRoute()
const { activeOperations, backups, progressFor, invalidate } = useServerBackupsQueue(
computed(() => ctx.serverId),
ctx.worldId,
)
const messages = defineMessages({
backgroundTaskRunning: {
id: 'servers.admonitions.background-task-running',
defaultMessage: 'Background task running',
},
contentBusyBody: {
id: 'content.page-layout.busy-description',
defaultMessage: 'Please wait for the operation to complete before editing content.',
},
filesBusyBody: {
id: 'files.layout.busy-warning',
defaultMessage: 'File operations are disabled while the operation is in progress.',
},
})
const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
const bannerCoversInstalling = computed(
() => ctx.server.value?.status === 'installing' || ctx.isSyncingContent.value,
)
function isBackupReason(id: string) {
return id === 'servers.busy.backup-creating' || id === 'servers.busy.backup-restoring'
}
function isInstallingReason(id: string) {
return id === 'servers.busy.installing' || id === 'servers.busy.syncing-content'
}
const filteredBusyReasons = computed(() =>
ctx.busyReasons.value.filter((r) => {
if (isBackupReason(r.reason.id)) return false
if (bannerCoversInstalling.value && isInstallingReason(r.reason.id)) return false
return true
}),
)
const contentBusyHeader = computed(() =>
filteredBusyReasons.value.length > 0 ? formatMessage(filteredBusyReasons.value[0].reason) : null,
)
const filesBusyHeader = computed(() =>
filteredBusyReasons.value.length > 0 ? formatMessage(filteredBusyReasons.value[0].reason) : null,
)
const dismissedIds = reactive(new Set<string>())
const cancellingIds = reactive(new Set<string>())
const dismissedContentErrorKey = ref<string | null>(null)
const contentErrorKey = computed(() =>
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
)
watch(contentErrorKey, (key) => {
if (!key) {
dismissedContentErrorKey.value = null
}
})
const backupAdmonitionEntries = computed<BackupAdmonitionEntry[]>(() => {
const result: BackupAdmonitionEntry[] = []
const backupById = new Map(backups.value.map((b) => [b.id, b]))
for (const op of activeOperations.value) {
const key = `${op.backup_id}:${op.operation_type}:${op.operation_id ?? 'legacy'}`
if (dismissedIds.has(key)) continue
const backup = backupById.get(op.backup_id)
const history = backup?.history.find(
(h) =>
h.operation_type === op.operation_type &&
(h.operation_id ?? null) === (op.operation_id ?? null),
)
const rawProgress = progressFor(op.backup_id, op.operation_type) ?? 0
result.push({
key,
backupId: op.backup_id,
type: op.operation_type,
state: history?.state ?? 'ongoing',
progress: rawProgress,
operationId: op.operation_id ?? null,
syntheticLegacy: op.synthetic_legacy,
name: backup?.name,
timestamp: history?.scheduled_for ?? op.scheduled_for,
})
}
for (const backup of backups.value) {
const last = backup.history[0]
if (!last || !last.should_prompt) continue
if (last.state === 'pending' || last.state === 'ongoing') continue
const key = `${backup.id}:${last.operation_type}:${last.operation_id ?? 'legacy'}`
if (dismissedIds.has(key)) continue
if (result.some((r) => r.key === key)) continue
result.push({
key,
backupId: backup.id,
type: last.operation_type,
state: last.state,
progress: 0,
operationId: last.operation_id ?? null,
syntheticLegacy: last.synthetic_legacy,
name: backup.name,
timestamp: last.completed_at ?? last.scheduled_for,
error: last.error ?? null,
})
}
return result
})
type ServerAdmonitionItem = StackedAdmonitionItem & {
priority: number
sortIndex: number
} & (
| { kind: 'installing' }
| { kind: 'upload' }
| { kind: 'fs-op'; op: FileOperation }
| { kind: 'backup'; entry: BackupAdmonitionEntry }
| { kind: 'busy-content' }
| { kind: 'busy-files' }
)
const showInstallingBanner = computed(() => {
if (!ctx.server.value) return false
const installing =
ctx.server.value.status === 'installing' || ctx.isSyncingContent.value || !!props.contentError
if (!installing) return false
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
return false
return props.syncProgress?.phase !== 'Analyzing'
})
function fsOpType(op: FileOperation): StackedAdmonitionItem['type'] {
if (op.state === 'done') return 'success'
if (op.state?.startsWith('fail')) return 'critical'
return 'info'
}
function fsOpPriority(op: FileOperation): number {
if (op.state?.startsWith('fail')) return 1
if (op.state === 'done') return 4
if (op.state === 'queued') return 3
return 2
}
function backupType(entry: BackupAdmonitionEntry): StackedAdmonitionItem['type'] {
if (entry.state === 'failed' || entry.state === 'timed_out') return 'critical'
if (entry.state === 'completed') return 'success'
return 'info'
}
function backupPriority(entry: BackupAdmonitionEntry): number {
if (entry.state === 'failed' || entry.state === 'timed_out') return 1
if (entry.state === 'ongoing') return 2
if (entry.state === 'pending') return 3
return 4
}
const stackItems = computed<ServerAdmonitionItem[]>(() => {
const out: ServerAdmonitionItem[] = []
let sortIndex = 0
if (showInstallingBanner.value) {
out.push({
id: 'installing',
type: props.contentError ? 'critical' : 'info',
dismissible: !!props.contentError,
kind: 'installing',
priority: 0,
sortIndex: sortIndex++,
})
}
if (ctx.uploadState.value.isUploading) {
out.push({
id: 'upload-active',
type: 'info',
dismissible: false,
kind: 'upload',
priority: 2,
sortIndex: sortIndex++,
})
}
for (const op of ctx.activeOperations.value) {
out.push({
id: op.id ? `fs-op-${op.id}` : `fs-op-${op.op}-${op.src}`,
type: fsOpType(op),
dismissible: !!op.id && (op.state === 'done' || !!op.state?.startsWith('fail')),
kind: 'fs-op',
op,
priority: fsOpPriority(op),
sortIndex: sortIndex++,
})
}
for (const entry of backupAdmonitionEntries.value) {
out.push({
id: `backup-${entry.key}`,
type: backupType(entry),
dismissible: entry.state !== 'pending' && entry.state !== 'ongoing',
kind: 'backup',
entry,
priority: backupPriority(entry),
sortIndex: sortIndex++,
})
}
if (contentBusyHeader.value) {
const p = isOnContentTab.value ? 0 : 5
out.push({
id: 'busy-content',
type: 'warning',
dismissible: false,
kind: 'busy-content',
priority: p,
sortIndex: sortIndex++,
})
}
if (filesBusyHeader.value) {
const p = isOnFilesTab.value ? 0 : 5
out.push({
id: 'busy-files',
type: 'warning',
dismissible: false,
kind: 'busy-files',
priority: p,
sortIndex: sortIndex++,
})
}
return out.sort((a, b) => a.priority - b.priority || a.sortIndex - b.sortIndex)
})
const hasBulkDismissableItems = computed(() => stackItems.value.some((it) => it.dismissible))
async function onBackupDismiss(item: BackupAdmonitionEntry) {
dismissedIds.add(item.key)
if (item.syntheticLegacy || item.operationId == null) {
await invalidate()
return
}
try {
if (item.type === 'create') {
await client.archon.backups_queue_v1.ackCreate(
ctx.serverId,
ctx.worldId.value!,
item.operationId,
)
} else {
await client.archon.backups_queue_v1.ackRestore(
ctx.serverId,
ctx.worldId.value!,
item.operationId,
)
}
} catch (err) {
dismissedIds.delete(item.key)
console.error('Failed to acknowledge backup operation', err)
} finally {
await invalidate()
}
}
async function onBackupCancel(item: BackupAdmonitionEntry) {
if (cancellingIds.has(item.key)) return
cancellingIds.add(item.key)
try {
await client.archon.backups_v1.delete(ctx.serverId, ctx.worldId.value!, item.backupId)
await invalidate()
} catch (err) {
cancellingIds.delete(item.key)
throw err
}
}
async function onBackupRetry(item: BackupAdmonitionEntry) {
await client.archon.backups_queue_v1.retry(ctx.serverId, ctx.worldId.value!, item.backupId)
dismissedIds.add(item.key)
await invalidate()
}
async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
if (!it.dismissible) continue
if (it.kind === 'installing' && props.contentError) {
onContentErrorDismiss()
} else if (it.kind === 'fs-op' && it.op.id) {
const { op } = it
if (op.state === 'done' || op.state?.startsWith('fail')) {
tasks.push(ctx.dismissOperation(it.op.id, 'dismiss'))
}
} else if (it.kind === 'backup') {
tasks.push(onBackupDismiss(it.entry))
}
}
await Promise.all(tasks)
}
function onFileOpDismiss(item: ServerAdmonitionItem) {
if (item.kind === 'fs-op' && item.op.id) {
void ctx.dismissOperation(item.op.id, 'dismiss')
}
}
function onContentErrorDismiss() {
if (contentErrorKey.value) {
dismissedContentErrorKey.value = contentErrorKey.value
}
}
</script>
<template>
<StackedAdmonitions
:items="stackItems"
:dismiss-all-enabled="hasBulkDismissableItems"
class="w-full"
@dismiss-all="onDismissAll"
>
<template #item="{ item, dismissible }">
<InstallingBanner
v-if="item.kind === 'installing'"
:progress="syncProgress"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
</template>
</InstallingBanner>
<UploadAdmonition v-else-if="item.kind === 'upload'" />
<FileOperationAdmonition
v-else-if="item.kind === 'fs-op'"
:op="item.op"
:dismissible="dismissible"
@dismiss="onFileOpDismiss(item)"
/>
<BackupAdmonition
v-else-if="item.kind === 'backup'"
:item="item.entry"
:dismissible="dismissible"
:cancelling="cancellingIds.has(item.entry.key)"
@dismiss="onBackupDismiss(item.entry)"
@cancel="onBackupCancel(item.entry)"
@retry="onBackupRetry(item.entry)"
/>
<Admonition
v-else-if="item.kind === 'busy-content'"
type="warning"
:header="formatMessage(messages.backgroundTaskRunning)"
>
{{ formatMessage(messages.contentBusyBody) }}
</Admonition>
<Admonition
v-else-if="item.kind === 'busy-files'"
type="warning"
:header="formatMessage(messages.backgroundTaskRunning)"
>
{{ formatMessage(messages.filesBusyBody) }}
</Admonition>
</template>
</StackedAdmonitions>
</template>

View File

@@ -0,0 +1,45 @@
<template>
<Admonition type="info" :progress="overallProgress" progress-color="blue">
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
{{
state.currentFileName
? `Uploading ${state.currentFileName} (${state.completedFiles}/${state.totalFiles})`
: `Uploading files (${state.completedFiles}/${state.totalFiles})`
}}
</template>
<span class="text-secondary">
{{ formatBytes(state.uploadedBytes) }} / {{ formatBytes(state.totalBytes) }} ({{
Math.round(overallProgress * 100)
}}%)
</span>
<template v-if="cancelUpload" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" type="button" @click="cancelUpload()">Cancel</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { injectModrinthServerContext } from '#ui/providers'
const ctx = injectModrinthServerContext()
const state = computed(() => ctx.uploadState.value)
const cancelUpload = computed(() => ctx.cancelUpload.value)
const overallProgress = computed(() => {
const s = state.value
if (!s.isUploading || s.totalFiles === 0) return 0
return Math.min((s.completedFiles + s.currentFileProgress) / s.totalFiles, 1)
})
</script>

View File

@@ -0,0 +1,5 @@
export type { BackupAdmonitionEntry } from './BackupAdmonition.vue'
export { default as BackupAdmonition } from './BackupAdmonition.vue'
export { default as FileOperationAdmonition } from './FileOperationAdmonition.vue'
export { default as ServerPanelAdmonitions } from './ServerPanelAdmonitions.vue'
export { default as UploadAdmonition } from './UploadAdmonition.vue'

View File

@@ -1,6 +1,6 @@
<template> <template>
<NewModal ref="modal" header="Create backup" @show="focusInput"> <NewModal ref="modal" header="Create backup" width="500px" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px] -mb-2"> <div class="flex flex-col gap-2 -mb-2">
<label for="backup-name-input"> <label for="backup-name-input">
<span class="text-lg font-semibold text-contrast">Name</span> <span class="text-lg font-semibold text-contrast">Name</span>
</label> </label>
@@ -45,9 +45,9 @@
</Transition> </Transition>
</div> </div>
<template #actions> <template #actions>
<div class="w-full flex flex-row gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-4" @click="hideModal"> <button class="!border !border-surface-4" @click="hideModal">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
@@ -84,14 +84,14 @@ const queryClient = useQueryClient()
const ctx = injectModrinthServerContext() const ctx = injectModrinthServerContext()
const props = defineProps<{ const props = defineProps<{
backups?: Archon.Backups.v1.Backup[] backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>() }>()
const backupsQueryKey = ['backups', 'list', ctx.serverId] const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (name: string) => mutationFn: (name: string) =>
client.archon.backups_v1.create(ctx.serverId, ctx.worldId.value!, { name }), client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }), onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
}) })

View File

@@ -1,28 +1,75 @@
<template> <template>
<NewModal ref="modal" header="Delete backup" fade="danger"> <NewModal
<div class="flex flex-col gap-6 max-w-[400px]"> ref="modal"
<Admonition type="critical" header="Delete warning"> :header="formatMessage(messages.header, { count })"
This backup will be permanently deleted. This action cannot be undone. fade="danger"
width="500px"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody, { count }) }}
</Admonition> </Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2"> <div v-if="displayBackups.length" class="flex min-w-0 flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span> <span class="font-semibold text-contrast">
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" /> {{ formatMessage(messages.backupsLabel, { count }) }}
</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="backupListRef"
class="flex max-h-[240px] flex-col gap-2 overflow-y-auto"
@scroll="checkScrollState"
>
<BackupItem
v-for="backup in displayBackups"
:key="backup.id"
:backup="backup"
preview
class="!bg-surface-2 !shadow-none"
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div> </div>
</div> </div>
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex justify-end gap-2">
<ButtonStyled> <ButtonStyled type="outlined">
<button @click="modal?.hide()"> <button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon /> <XIcon />
Cancel {{ formatMessage(commonMessages.cancelButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="red"> <ButtonStyled color="red">
<button @click="deleteBackup"> <button @click="confirmDelete">
<TrashIcon /> <TrashIcon />
Delete backup {{ formatMessage(messages.confirm, { count }) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -33,31 +80,86 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { TrashIcon, XIcon } from '@modrinth/assets' import { TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { useScrollIndicator } from '../../../composables/scroll-indicator'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue' import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue' import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue' import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue' import BackupItem from './BackupItem.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'delete', backup: Archon.Backups.v1.Backup | undefined): void (e: 'delete', backup: Archon.BackupsQueue.v1.BackupQueueBackup | undefined): void
(e: 'bulk-delete', backups: Archon.BackupsQueue.v1.BackupQueueBackup[]): void
}>() }>()
const modal = ref<InstanceType<typeof NewModal>>() const messages = defineMessages({
const currentBackup = ref<Archon.Backups.v1.Backup>() header: {
id: 'servers.backups.delete-modal.header',
defaultMessage: 'Delete {count, plural, one {backup} other {backups}}',
},
admonitionHeader: {
id: 'servers.backups.delete-modal.admonition-header',
defaultMessage: 'Deletion warning',
},
admonitionBody: {
id: 'servers.backups.delete-modal.admonition-body',
defaultMessage:
'Once deleted, {count, plural, one {this backup cannot} other {these backups cannot}} be recovered. Deletion is permanent.',
},
confirm: {
id: 'servers.backups.delete-modal.confirm',
defaultMessage: 'Delete {count, plural, one {backup} other {# backups}}',
},
backupsLabel: {
id: 'servers.backups.delete-modal.backups-label',
defaultMessage: '{count, plural, one {Backup} other {Backups ({count})}}',
},
})
function show(backup: Archon.Backups.v1.Backup) { const modal = ref<InstanceType<typeof NewModal>>()
currentBackup.value = backup const backupListRef = ref<HTMLElement | null>(null)
const singleBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup>()
const bulkBackups = ref<Archon.BackupsQueue.v1.BackupQueueBackup[]>([])
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(backupListRef)
const isBulk = computed(() => bulkBackups.value.length > 0)
const count = computed(() => (isBulk.value ? bulkBackups.value.length : 1))
const displayBackups = computed(() =>
isBulk.value ? bulkBackups.value : singleBackup.value ? [singleBackup.value] : [],
)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
singleBackup.value = backup
bulkBackups.value = []
modal.value?.show() modal.value?.show()
nextTick(() => forceCheck())
} }
function deleteBackup() { function showBulk(backups: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
singleBackup.value = undefined
bulkBackups.value = [...backups]
modal.value?.show()
nextTick(() => forceCheck())
}
function confirmDelete() {
modal.value?.hide() modal.value?.hide()
emit('delete', currentBackup.value) if (isBulk.value) {
emit('bulk-delete', bulkBackups.value)
bulkBackups.value = []
} else {
emit('delete', singleBackup.value)
}
} }
defineExpose({ defineExpose({
show, show,
showBulk,
}) })
</script> </script>

View File

@@ -2,20 +2,19 @@
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { import {
ClipboardCopyIcon, ClipboardCopyIcon,
ClockIcon,
DownloadIcon, DownloadIcon,
EditIcon, EditIcon,
MoreVerticalIcon, MoreVerticalIcon,
RotateCounterClockwiseIcon, RotateCounterClockwiseIcon,
ShieldIcon,
TrashIcon, TrashIcon,
UserRoundIcon, UserRoundIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useFormatDateTime } from '../../../composables' import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n' import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils' import { commonMessages, truncatedTooltip } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue' import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue' import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
@@ -26,19 +25,20 @@ const formatDateTime = useFormatDateTime({
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'retry'): void (e: 'download' | 'rename' | 'restore'): void
(e: 'delete', skipConfirmation?: boolean): void (e: 'delete', skipConfirmation?: boolean): void
}>() }>()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
backup: Archon.Backups.v1.Backup backup: Archon.BackupsQueue.v1.BackupQueueBackup
preview?: boolean preview?: boolean
kyrosUrl?: string kyrosUrl?: string
jwt?: string jwt?: string
showCopyIdAction?: boolean showCopyIdAction?: boolean
showDebugInfo?: boolean showDebugInfo?: boolean
restoreDisabled?: string restoreDisabled?: string
selected?: boolean
}>(), }>(),
{ {
preview: false, preview: false,
@@ -47,45 +47,15 @@ const props = withDefaults(
showCopyIdAction: false, showCopyIdAction: false,
showDebugInfo: false, showDebugInfo: false,
restoreDisabled: undefined, restoreDisabled: undefined,
selected: false,
}, },
) )
const failedToCreate = computed( const nameRef = ref<HTMLElement | null>(null)
() => props.backup.status === 'error' || props.backup.status === 'timed_out',
)
const inactiveStates = ['failed', 'cancelled', 'done']
const creating = computed(() => {
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return true
}
if (
(props.backup.status === 'in_progress' || props.backup.status === 'pending') &&
!props.backup.task?.restore
) {
return true
}
return false
})
const restoring = computed(() => {
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return true
}
return false
})
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const activeOperation = computed(() => creating.value || restoring.value)
const backupIcon = computed(() => { const backupIcon = computed(() => {
if (props.backup.automated) { if (props.backup.automated) {
return ClockIcon return ShieldIcon
} }
return UserRoundIcon return UserRoundIcon
}) })
@@ -100,29 +70,25 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
}) })
} }
if (!activeOperation.value) { if (options.length > 0) {
if (options.length > 0) { options.push({ divider: true })
options.push({ divider: true })
}
options.push({
id: 'download',
action: () => emit('download'),
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
disabled: !props.kyrosUrl || !props.jwt,
})
} }
options.push({
id: 'download',
action: () => emit('download'),
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
disabled: !props.kyrosUrl || !props.jwt,
})
options.push({ id: 'rename', action: () => emit('rename') }) options.push({ id: 'rename', action: () => emit('rename') })
if (!activeOperation.value) { options.push({ divider: true })
options.push({ divider: true }) options.push({
options.push({ id: 'delete',
id: 'delete', color: 'red',
color: 'red', action: () => emit('delete'),
action: () => emit('delete'), })
})
}
return options return options
}) })
@@ -131,13 +97,6 @@ async function copyId() {
await navigator.clipboard.writeText(props.backup.id) await navigator.clipboard.writeText(props.backup.id)
} }
// TODO: Uncomment when API supports size field
// const formatBytes = (bytes?: number) => {
// if (!bytes) return ''
// const mb = bytes / (1024 * 1024)
// return `${mb.toFixed(0)} MiB`
// }
const messages = defineMessages({ const messages = defineMessages({
restore: { restore: {
id: 'servers.backups.item.restore', id: 'servers.backups.item.restore',
@@ -147,14 +106,6 @@ const messages = defineMessages({
id: 'servers.backups.item.rename', id: 'servers.backups.item.rename',
defaultMessage: 'Rename', defaultMessage: 'Rename',
}, },
failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
},
failedToRestoreBackup: {
id: 'servers.backups.item.failed-to-restore-backup',
defaultMessage: 'Failed to restore from backup',
},
auto: { auto: {
id: 'servers.backups.item.auto', id: 'servers.backups.item.auto',
defaultMessage: 'Auto', defaultMessage: 'Auto',
@@ -171,78 +122,67 @@ const messages = defineMessages({
</script> </script>
<template> <template>
<div <div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md" class="flex items-center gap-4 rounded-[20px] border border-solid bg-surface-3 p-4 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.3),0px_1px_3px_0px_rgba(0,0,0,0.15)]"
:class=" :class="props.selected ? 'border-brand-green' : 'border-transparent'"
preview
? 'grid-cols-1'
: 'grid-cols-[auto_1fr_auto] md:grid-cols-[minmax(0,1fr)_400px_minmax(0,1fr)]'
"
> >
<div class="flex flex-row gap-4 items-center"> <div class="flex min-w-0 flex-1 items-center gap-4">
<!-- Icon tile -->
<div <div
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border-solid border-[1px] border-surface-5 bg-surface-4 md:size-16" class="flex shrink-0 items-center justify-center rounded-2xl border border-solid border-surface-5 bg-surface-4"
:class="preview ? 'size-10' : 'size-14'"
> >
<component :is="backupIcon" class="size-7 text-secondary md:size-10" /> <component
:is="backupIcon"
class="text-secondary"
:class="preview ? 'size-6' : 'size-10'"
/>
</div> </div>
<!-- Name + badge + subtitle -->
<div class="flex min-w-0 flex-col gap-1.5"> <div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<span class="truncate font-semibold text-contrast max-w-[400px]">{{ backup.name }}</span> <span
ref="nameRef"
v-tooltip="truncatedTooltip(nameRef, backup.name)"
class="min-w-0 truncate font-semibold text-contrast"
>
{{ backup.name }}
</span>
<span <span
v-if="backup.automated" v-if="backup.automated"
class="rounded-full border-solid border-[1px] border-surface-5 bg-surface-4 px-2.5 py-1 text-sm text-secondary" class="shrink-0 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1 text-sm font-medium text-secondary"
> >
{{ formatMessage(messages.auto) }} {{ formatMessage(messages.auto) }}
</span> </span>
</div> </div>
<div class="flex items-center gap-1.5 text-sm text-secondary"> <div class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<template v-if="preview"> <template v-if="preview">
<span>{{ formatDateTime(backup.created_at) }}</span> <span>{{ formatDateTime(backup.created_at) }}</span>
</template> </template>
<template v-else-if="failedToCreate || failedToRestore">
<XIcon class="size-4 text-red" />
<span class="text-red">
{{
formatMessage(
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
)
}}
</span>
</template>
<template v-else> <template v-else>
<!-- TODO: Uncomment when API supports creator_id field -->
<!-- <template v-if="backup.creator_id && backup.creator_id !== 'auto'">
<Avatar ... class="size-6 rounded-full" />
<span>{{ creatorName }}</span>
</template>
<template v-else> -->
<span> <span>
{{ {{
formatMessage(backup.automated ? messages.backupSchedule : messages.manualBackup) formatMessage(backup.automated ? messages.backupSchedule : messages.manualBackup)
}} }}
</span> </span>
<!-- </template> -->
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<div <!-- Date (middle column) -->
v-if="!preview" <div v-if="!preview" class="flex shrink-0 items-center">
class="col-span-full row-start-2 flex flex-col gap-2 md:col-span-1 md:row-start-auto md:items-center" <span class="whitespace-nowrap font-medium text-contrast">{{
> formatDateTime(backup.created_at)
<span class="w-full font-medium text-contrast md:text-center"> }}</span>
{{ formatDateTime(backup.created_at) }}
</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
</div> </div>
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end"> <!-- Right side actions -->
<ButtonStyled v-if="!activeOperation" color="brand" type="outlined"> <div v-if="!preview" class="flex min-w-0 flex-1 items-center justify-end gap-2">
<ButtonStyled color="brand" type="outlined">
<button <button
v-tooltip="props.restoreDisabled" v-tooltip="props.restoreDisabled"
class="!border-[1px]" class="!border"
:disabled="!!props.restoreDisabled" :disabled="!!props.restoreDisabled"
@click="() => emit('restore')" @click="() => emit('restore')"
> >

View File

@@ -1,396 +0,0 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, watch } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
import type { BackupProgressEntry } from '../../../providers/server-context'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { serverId, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', serverId]
const { data: backupsList } = useQuery({
queryKey: backupsQueryKey,
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
enabled: computed(() => !!worldId.value),
})
interface TerminalEntry {
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
backupName?: string
createdAt?: string
}
interface AdmonitionEntry {
key: string
backupId: string
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
progress: number
name?: string
createdAt?: string
}
const terminalEntries = reactive(new Map<string, TerminalEntry>())
const dismissedIds = reactive(new Set<string>())
function findBackup(backupId: string) {
return backupsList.value?.find((b) => b.id === backupId)
}
watch(
() => [...backupsState.entries()] as [string, BackupProgressEntry][],
(entries) => {
for (const [id, entry] of entries) {
const backup = findBackup(id)
if (entry.create?.state === 'failed') {
terminalEntries.set(`${id}:create`, {
type: 'create',
state: 'failed',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
if (entry.restore?.state === 'done') {
terminalEntries.set(`${id}:restore`, {
type: 'restore',
state: 'done',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
if (entry.restore?.state === 'failed') {
terminalEntries.set(`${id}:restore`, {
type: 'restore',
state: 'failed',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
}
},
{ deep: true },
)
const admonitions = computed<AdmonitionEntry[]>(() => {
const result: AdmonitionEntry[] = []
const seenIds = new Set<string>()
for (const [id, entry] of backupsState.entries()) {
const backup = findBackup(id)
seenIds.add(id)
if (entry.create && entry.create.state === 'ongoing') {
const key = `${id}:create`
if (!dismissedIds.has(key)) {
result.push({
key,
backupId: id,
type: 'create',
state: entry.create.state,
progress: entry.create.progress,
name: backup?.name,
createdAt: backup?.created_at,
})
}
}
if (entry.restore && entry.restore.state === 'ongoing') {
const key = `${id}:restore`
if (!dismissedIds.has(key)) {
result.push({
key,
backupId: id,
type: 'restore',
state: entry.restore.state,
progress: entry.restore.progress,
name: backup?.name,
createdAt: backup?.created_at,
})
}
}
}
if (backupsList.value) {
for (const backup of backupsList.value) {
if (seenIds.has(backup.id)) continue
if (backup.status === 'pending' || backup.status === 'in_progress') {
const key = `${backup.id}:create`
if (!dismissedIds.has(key)) {
result.push({
key,
backupId: backup.id,
type: 'create',
state: 'ongoing',
progress: 0,
name: backup.name,
createdAt: backup.created_at,
})
}
}
}
}
for (const [key, entry] of terminalEntries.entries()) {
if (dismissedIds.has(key)) continue
if (result.some((r) => r.key === key)) continue
const backupId = key.split(':')[0]
const backup = findBackup(backupId)
result.push({
key,
backupId,
type: entry.type,
state: entry.state,
progress: entry.state === 'done' ? 1 : 0,
name: backup?.name ?? entry.backupName,
createdAt: backup?.created_at ?? entry.createdAt,
})
}
return result
})
function handleCancel(backupId: string) {
client.archon.backups_v1.delete(serverId, worldId.value!, backupId).then(() => {
markBackupCancelled(backupId)
backupsState.delete(backupId)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
})
}
function handleRetry(backupId: string, key: string) {
client.archon.backups_v1.retry(serverId, worldId.value!, backupId).then(() => {
terminalEntries.delete(key)
dismissedIds.delete(key)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
})
}
function handleDismiss(key: string) {
dismissedIds.add(key)
terminalEntries.delete(key)
}
function getAdmonitionType(state: Archon.Backups.v1.BackupState): 'info' | 'critical' | 'success' {
if (state === 'failed') return 'critical'
if (state === 'done') return 'success'
return 'info'
}
function getIcon(state: Archon.Backups.v1.BackupState) {
if (state === 'failed') return TriangleAlertIcon
if (state === 'done') return CheckCircleIcon
return InfoIcon
}
function getButtonColor(state: Archon.Backups.v1.BackupState): 'red' | 'green' | 'blue' {
if (state === 'failed') return 'red'
if (state === 'done') return 'green'
return 'blue'
}
function isQueued(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress === 0
}
function isInProgress(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress > 0
}
function getTitle(item: AdmonitionEntry) {
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.creatingBackupTitle)
if (item.state === 'failed') return formatMessage(messages.backupFailedTitle)
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.restoringBackupTitle)
if (item.state === 'done') return formatMessage(messages.restoreSuccessfulTitle)
if (item.state === 'failed') return formatMessage(messages.restoreFailedTitle)
return ''
}
function getDescription(item: AdmonitionEntry) {
const backupName = item.name ?? formatMessage(messages.fallbackName)
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.creatingBackupDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.backupFailedDescription, { backupName })
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.restoringBackupDescription, { backupName })
if (item.state === 'done')
return formatMessage(messages.restoreSuccessfulDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.restoreFailedDescription, { backupName })
return ''
}
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restoring from backup queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restoring from backup successful',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restoring from backup failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
})
</script>
<template>
<TransitionGroup
v-if="admonitions.length > 0"
name="backup-admonition"
tag="div"
class="flex flex-col gap-3"
>
<Admonition v-for="item in admonitions" :key="item.key" :type="getAdmonitionType(item.state)">
<template #icon="{ iconClass }">
<component :is="getIcon(item.state)" :class="iconClass" />
</template>
<template #header>
<div class="flex items-center gap-2">
<span>{{ getTitle(item) }}</span>
<div v-if="item.createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(item.createdAt) }}</span>
</div>
</div>
</template>
{{ getDescription(item) }}
<template #top-right-actions>
<ButtonStyled v-if="isQueued(item) || isInProgress(item)" type="outlined" color="blue">
<button class="!border" @click="handleCancel(item.backupId)">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="item.state === 'failed'" color="red">
<button @click="handleRetry(item.backupId, item.key)">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="item.state === 'failed' || item.state === 'done'"
circular
type="transparent"
hover-color-fill="background"
:color="getButtonColor(item.state)"
>
<button @click="handleDismiss(item.key)">
<XIcon />
</button>
</ButtonStyled>
</template>
<template v-if="isInProgress(item)" #progress>
<div class="pl-9">
<ProgressBar
:progress="item.progress"
color="blue"
:waiting="item.progress === 0"
full-width
/>
</div>
</template>
</Admonition>
</TransitionGroup>
</template>
<style scoped>
.backup-admonition-enter-active,
.backup-admonition-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.backup-admonition-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.backup-admonition-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.backup-admonition-move {
transition: transform 300ms ease-in-out;
}
</style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput"> <NewModal ref="modal" header="Renaming backup" width="500px" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]"> <div class="flex flex-col gap-2">
<label for="backup-name-input"> <label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span> <span class="text-lg font-semibold text-contrast"> Name </span>
</label> </label>
@@ -20,26 +20,28 @@
</span> </span>
</div> </div>
</div> </div>
<div class="mt-2 flex justify-start gap-2"> <template #actions>
<ButtonStyled color="brand"> <div class="flex gap-2 justify-end">
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup"> <ButtonStyled type="outlined">
<template v-if="renameMutation.isPending.value"> <button class="!border !border-surface-4" @click="hide">
<SpinnerIcon class="animate-spin" /> <XIcon />
Renaming... Cancel
</template> </button>
<template v-else> </ButtonStyled>
<SaveIcon /> <ButtonStyled color="brand">
Save changes <button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
</template> <template v-if="renameMutation.isPending.value">
</button> <SpinnerIcon class="animate-spin" />
</ButtonStyled> Renaming...
<ButtonStyled> </template>
<button @click="hide"> <template v-else>
<XIcon /> <SaveIcon />
Cancel Save changes
</button> </template>
</ButtonStyled> </button>
</div> </ButtonStyled>
</div>
</template>
</NewModal> </NewModal>
</template> </template>
@@ -64,10 +66,10 @@ const queryClient = useQueryClient()
const ctx = injectModrinthServerContext() const ctx = injectModrinthServerContext()
const props = defineProps<{ const props = defineProps<{
backups?: Archon.Backups.v1.Backup[] backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>() }>()
const backupsQueryKey = ['backups', 'list', ctx.serverId] const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) => mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
@@ -80,7 +82,7 @@ const input = ref<HTMLInputElement>()
const backupName = ref('') const backupName = ref('')
const originalName = ref('') const originalName = ref('')
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null) const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
const trimmedName = computed(() => backupName.value.trim()) const trimmedName = computed(() => backupName.value.trim())
@@ -110,7 +112,7 @@ const focusInput = () => {
}) })
} }
function show(backup: Archon.Backups.v1.Backup) { function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
currentBackup.value = backup currentBackup.value = backup
backupName.value = backup.name backupName.value = backup.name
originalName.value = backup.name originalName.value = backup.name

View File

@@ -1,10 +1,10 @@
<template> <template>
<NewModal ref="modal" header="Restore backup" fade="danger"> <NewModal ref="modal" header="Restore backup" fade="danger" width="500px">
<div class="flex flex-col gap-6 max-w-[400px]"> <div class="flex flex-col gap-6">
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running"> <Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
Stop the server before restoring a backup. Stop the server before restoring a backup.
</Admonition> </Admonition>
<Admonition v-else type="critical" header="Restore warning"> <Admonition v-else type="critical" header="Your server files will be replaced">
Restoring your server will replace the current world and server files. Any changes made Restoring your server will replace the current world and server files. Any changes made
since that backup will be permanently lost. since that backup will be permanently lost.
</Admonition> </Admonition>
@@ -17,8 +17,8 @@
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled> <ButtonStyled type="outlined">
<button @click="modal?.hide()"> <button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
@@ -56,18 +56,24 @@ const client = injectModrinthClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const ctx = injectModrinthServerContext() const ctx = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', ctx.serverId] const backupsQueryKey = ['backups', 'queue', ctx.serverId]
function safetyBackupName(backupName: string) {
const base = `Before restoring "${backupName}"`
return base.slice(0, 92)
}
const restoreMutation = useMutation({ const restoreMutation = useMutation({
mutationFn: (backupId: string) => mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_v1.restore(ctx.serverId, ctx.worldId.value!, backupId), client.archon.backups_queue_v1.restore(ctx.serverId, ctx.worldId.value!, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }), onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
}) })
const modal = ref<InstanceType<typeof NewModal>>() const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null) const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
const isRestoring = ref(false) const isRestoring = ref(false)
function show(backup: Archon.Backups.v1.Backup) { function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
currentBackup.value = backup currentBackup.value = backup
modal.value?.show() modal.value?.show()
} }
@@ -85,22 +91,24 @@ const restoreBackup = () => {
} }
isRestoring.value = true isRestoring.value = true
restoreMutation.mutate(currentBackup.value.id, { restoreMutation.mutate(
onSuccess: () => { {
// Optimistically update backupsState to show restore in progress immediately backupId: currentBackup.value.id,
ctx.backupsState.set(currentBackup.value!.id, { name: safetyBackupName(currentBackup.value.name),
restore: { progress: 0, state: 'ongoing' },
})
modal.value?.hide()
}, },
onError: (error) => { {
const message = error instanceof Error ? error.message : String(error) onSuccess: () => {
addNotification({ type: 'error', title: 'Failed to restore backup', text: message }) modal.value?.hide()
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
},
onSettled: () => {
isRestoring.value = false
},
}, },
onSettled: () => { )
isRestoring.value = false
},
})
} }
defineExpose({ defineExpose({

View File

@@ -1,7 +1,6 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue' export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue' export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue' export { default as BackupItem } from './BackupItem.vue'
export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue' export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue' export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
export { default as BackupWarning } from './BackupWarning.vue' export { default as BackupWarning } from './BackupWarning.vue'

View File

@@ -1,3 +1,4 @@
export * from './admonitions'
export * from './backups' export * from './backups'
export * from './flows' export * from './flows'
export * from './icons' export * from './icons'

View File

@@ -9,6 +9,7 @@ export * from './i18n-debug'
export * from './page-leave-safety' export * from './page-leave-safety'
export * from './scroll-indicator' export * from './scroll-indicator'
export * from './server-backup' export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console' export * from './server-console'
export * from './server-manage-core-runtime' export * from './server-manage-core-runtime'
export * from './sticky-observer' export * from './sticky-observer'

View File

@@ -20,7 +20,7 @@ export function useScrollIndicator(
containerRef: Ref<HTMLElement | null>, containerRef: Ref<HTMLElement | null>,
options: ScrollIndicatorOptions = {}, options: ScrollIndicatorOptions = {},
): ScrollIndicator { ): ScrollIndicator {
const { watchContent = true, debounceMs = 10, tolerance = 1, debug = false } = options const { watchContent = true, debounceMs = 0, tolerance = 1, debug = false } = options
const showTopFade = ref(false) const showTopFade = ref(false)
const showBottomFade = ref(false) const showBottomFade = ref(false)

View File

@@ -0,0 +1,133 @@
import type { Archon } from '@modrinth/api-client'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, type Ref } from 'vue'
import { type BusyReason, injectModrinthClient } from '#ui/providers'
import { defineMessage } from './i18n'
type ProgressKey = `${string}:${'create' | 'restore'}`
export function useServerBackupsQueue(serverId: Ref<string>, worldId: Ref<string | null>) {
const client = injectModrinthClient()
const queryClient = useQueryClient()
const queryKey = computed(() => ['backups', 'queue', serverId.value] as const)
const progressOverlay = reactive(new Map<ProgressKey, number>())
const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
const query = useQuery({
queryKey,
queryFn: () => client.archon.backups_queue_v1.list(serverId.value, worldId.value!),
enabled: computed(() => !!worldId.value),
refetchInterval: (q) => {
const data = q.state.data as Archon.BackupsQueue.v1.BackupsQueueResponse | undefined
return data?.active_operations?.length ? 3000 : false
},
})
const data = computed(() => query.data.value)
const activeOperations = computed(() => data.value?.active_operations ?? [])
const backups = computed(() =>
[...(data.value?.backups ?? [])].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
)
const activeOperationByBackupId = computed(() => {
const map = new Map<string, Archon.BackupsQueue.v1.ActiveOperation>()
for (const op of activeOperations.value) map.set(op.backup_id, op)
return map
})
const backupById = computed(() => {
const map = new Map<string, Archon.BackupsQueue.v1.BackupQueueBackup>()
for (const backup of backups.value) map.set(backup.id, backup)
return map
})
const hasActiveCreate = computed(() =>
activeOperations.value.some((o) => o.operation_type === 'create' && !o.has_parent),
)
const hasActiveRestore = computed(() =>
activeOperations.value.some((o) => o.operation_type === 'restore'),
)
const hasRunningCreate = computed(() =>
activeOperations.value.some(
(o) =>
o.operation_type === 'create' &&
!o.has_parent &&
backupById.value.get(o.backup_id)?.status === 'in_progress',
),
)
const hasRunningRestore = computed(() =>
activeOperations.value.some(
(o) =>
o.operation_type === 'restore' &&
backupById.value.get(o.backup_id)?.status === 'in_progress',
),
)
function handleWsBackupProgress(evt: Archon.Websocket.v0.WSBackupProgressEvent) {
if (evt.task === 'file') return
const key = `${evt.id}:${evt.task}` as ProgressKey
if (evt.state === 'ongoing') {
progressOverlay.set(key, evt.progress)
} else {
progressOverlay.delete(key)
}
const prev = lastSeenState.get(key)
if (prev !== evt.state) {
lastSeenState.set(key, evt.state)
queryClient.invalidateQueries({ queryKey: queryKey.value })
}
}
function progressFor(backupId: string, kind: 'create' | 'restore'): number | undefined {
return progressOverlay.get(`${backupId}:${kind}`)
}
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (hasRunningCreate.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
}
if (hasRunningRestore.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
}
return reasons
})
async function invalidate() {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
return {
query,
queryKey,
data,
activeOperations,
activeOperationByBackupId,
backups,
hasActiveCreate,
hasActiveRestore,
hasRunningCreate,
hasRunningRestore,
progressFor,
handleWsBackupProgress,
busyReasons,
invalidate,
}
}

View File

@@ -1,5 +1,5 @@
import { createGlobalState } from '@vueuse/core' import { createGlobalState } from '@vueuse/core'
import { type Ref, shallowRef, triggerRef } from 'vue' import { type Ref, ref, shallowRef, triggerRef } from 'vue'
import { detectLogLevel } from '../layouts/shared/console/composables/log-level' import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types' import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
@@ -52,6 +52,8 @@ function groupContinuations(lines: LogLine[]): LogLine[] {
const batchTimeout = 300 const batchTimeout = 300
const initialBatchSize = 256 const initialBatchSize = 256
const initialHydrationQuietMs = 700
const initialHydrationMaxMs = 2000
const LogLevelCode = { const LogLevelCode = {
None: 0, None: 0,
@@ -224,10 +226,43 @@ export function createConsoleState() {
let lineBuffer: LogLine[] = [] let lineBuffer: LogLine[] = []
let batchTimer: NodeJS.Timeout | null = null let batchTimer: NodeJS.Timeout | null = null
let initialHydrationQuietTimer: ReturnType<typeof setTimeout> | null = null
let initialHydrationMaxTimer: ReturnType<typeof setTimeout> | null = null
const isInitialLogHydrating = ref(false)
let wrapCount = 0 let wrapCount = 0
let lastFlushMs = 0 let lastFlushMs = 0
const clearInitialHydrationTimers = (): void => {
if (initialHydrationQuietTimer) {
clearTimeout(initialHydrationQuietTimer)
initialHydrationQuietTimer = null
}
if (initialHydrationMaxTimer) {
clearTimeout(initialHydrationMaxTimer)
initialHydrationMaxTimer = null
}
}
const settleInitialLogHydration = (): void => {
if (!isInitialLogHydrating.value) return
clearInitialHydrationTimers()
flushBuffer()
isInitialLogHydrating.value = false
}
const armInitialHydrationQuietTimer = (): void => {
if (initialHydrationQuietTimer) clearTimeout(initialHydrationQuietTimer)
initialHydrationQuietTimer = setTimeout(settleInitialLogHydration, initialHydrationQuietMs)
}
const beginInitialLogHydration = (): void => {
clearInitialHydrationTimers()
isInitialLogHydrating.value = true
armInitialHydrationQuietTimer()
initialHydrationMaxTimer = setTimeout(settleInitialLogHydration, initialHydrationMaxMs)
}
const flushBuffer = (): void => { const flushBuffer = (): void => {
if (lineBuffer.length === 0) return if (lineBuffer.length === 0) return
@@ -269,6 +304,12 @@ export function createConsoleState() {
} }
const addLines = (lines: LogLine[]): void => { const addLines = (lines: LogLine[]): void => {
if (isInitialLogHydrating.value) {
lineBuffer.push(...lines)
armInitialHydrationQuietTimer()
return
}
if (output.value.length === 0 && lines.length >= initialBatchSize) { if (output.value.length === 0 && lines.length >= initialBatchSize) {
lineBuffer = lines lineBuffer = lines
flushBuffer() flushBuffer()
@@ -326,6 +367,8 @@ export function createConsoleState() {
lineBuffer = [] lineBuffer = []
wsEventHistory.length = 0 wsEventHistory.length = 0
wrapCount = 0 wrapCount = 0
isInitialLogHydrating.value = false
clearInitialHydrationTimers()
if (batchTimer) { if (batchTimer) {
clearTimeout(batchTimer) clearTimeout(batchTimer)
batchTimer = null batchTimer = null
@@ -359,6 +402,8 @@ export function createConsoleState() {
return { return {
output, output,
isInitialLogHydrating,
beginInitialLogHydration,
addLines, addLines,
addLog4jEvent, addLog4jEvent,
addLegacyLog, addLegacyLog,

View File

@@ -6,7 +6,7 @@ import {
} from '@modrinth/api-client' } from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils' import type { Stats } from '@modrinth/utils'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue' import { computed, ref } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types' import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers' import { injectModrinthClient, provideModrinthServerContext } from '../providers'
@@ -27,8 +27,7 @@ type UseServerManageCoreRuntimeOptions = {
worldId: ReadableRef<string | null> worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined> server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
isSyncingContent: ReadableRef<boolean> isSyncingContent: ReadableRef<boolean>
markBackupCancelled?: (backupId: string) => void extraBusyReasons?: ComputedRef<BusyReason[]>
includeBackupBusyReasons?: boolean
setDisconnectedOnAuthIncorrect?: boolean setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean syncUptimeFromState?: boolean
incrementUptimeLocally?: boolean incrementUptimeLocally?: boolean
@@ -94,7 +93,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const isServerRunning = computed(() => serverPowerState.value === 'running') const isServerRunning = computed(() => serverPowerState.value === 'running')
const stats = ref<Stats>(createInitialStats()) const stats = ref<Stats>(createInitialStats())
const uptimeSeconds = ref(0) const uptimeSeconds = ref(0)
const backupsState = reactive(new Map())
const fsAuth = ref<{ url: string; token: string } | null>(null) const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([]) const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]) const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
@@ -107,12 +105,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
let staleStatsIntervalId: ReturnType<typeof setInterval> | null = null let staleStatsIntervalId: ReturnType<typeof setInterval> | null = null
const markBackupCancelled =
options.markBackupCancelled ??
((backupId: string) => {
backupsState.delete(backupId)
})
const busyReasons = computed<BusyReason[]>(() => { const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = [] const reasons: BusyReason[] = []
if (options.server.value?.status === 'installing') { if (options.server.value?.status === 'installing') {
@@ -131,28 +123,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}), }),
}) })
} }
if (options.includeBackupBusyReasons) { if (options.extraBusyReasons) reasons.push(...options.extraBusyReasons.value)
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
break
}
if (entry.restore?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
break
}
}
}
return reasons return reasons
}) })
@@ -353,6 +324,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
isWsAuthIncorrect.value = false isWsAuthIncorrect.value = false
modrinthServersConsole.clear() modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
const baseSubscriptions: SocketUnsubscriber[] = [ const baseSubscriptions: SocketUnsubscriber[] = [
client.archon.sockets.on(targetServerId, 'log', handleLog), client.archon.sockets.on(targetServerId, 'log', handleLog),
@@ -405,20 +377,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
} }
} }
watch(
() => fsOps.value,
(newOps) => {
for (const op of newOps) {
if (op.state === 'done' && op.id && !dismissedOpIds.value.has(op.id)) {
setTimeout(() => {
dismissOperation(op.id!, 'dismiss')
}, 3000)
}
}
},
{ deep: true },
)
const refreshFsAuth = async () => { const refreshFsAuth = async () => {
if (!options.serverId.value) { if (!options.serverId.value) {
fsAuth.value = null fsAuth.value = null
@@ -440,8 +398,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
isServerRunning, isServerRunning,
stats, stats,
uptimeSeconds, uptimeSeconds,
backupsState,
markBackupCancelled,
isSyncingContent: options.isSyncingContent as Ref<boolean>, isSyncingContent: options.isSyncingContent as Ref<boolean>,
busyReasons, busyReasons,
fsAuth, fsAuth,
@@ -463,7 +419,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
return { return {
activeOperations, activeOperations,
backupsState,
busyReasons, busyReasons,
cancelUpload, cancelUpload,
cleanupCoreRuntime, cleanupCoreRuntime,

View File

@@ -100,7 +100,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
serverFilterTypes, serverFilterTypes,
serverRequestParams, serverRequestParams,
createServerPageParams, createServerPageParams,
} = useServerSearch({ tags: options.tags, query, maxResults, currentPage }) } = useServerSearch({
tags: options.tags,
query,
maxResults,
currentPage,
providedFilters: options.providedFilters,
})
const effectiveRequestParams = computed(() => const effectiveRequestParams = computed(() =>
isServerType.value ? serverRequestParams.value : requestParams.value, isServerType.value ? serverRequestParams.value : requestParams.value,

View File

@@ -174,7 +174,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:disabled="action.disabled" :disabled="action.disabled"
@click.stop="action.onClick" @click.stop="action.onClick"
> >
<component :is="action.icon" /> <component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template> <template v-if="!action.circular">{{ action.label }}</template>
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -241,7 +241,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:disabled="action.disabled" :disabled="action.disabled"
@click.stop="action.onClick" @click.stop="action.onClick"
> >
<component :is="action.icon" /> <component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template> <template v-if="!action.circular">{{ action.label }}</template>
</button> </button>
</ButtonStyled> </ButtonStyled>

View File

@@ -98,7 +98,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
> >
<Checkbox <Checkbox
v-model="ctx.hideInstalled!.value" v-model="ctx.hideInstalled!.value"
:label="ctx.hideInstalledLabel?.value ?? 'Hide installed content'" :label="ctx.hideInstalledLabel?.value ?? 'Hide already installed content'"
class="filter-checkbox" class="filter-checkbox"
@update:model-value="ctx.onFilterChange()" @update:model-value="ctx.onFilterChange()"
@click.prevent.stop @click.prevent.stop

View File

@@ -30,6 +30,7 @@ export interface CardAction {
key: string key: string
label: string label: string
icon: Component icon: Component
iconClass?: string
disabled?: boolean disabled?: boolean
color?: 'brand' | 'red' color?: 'brand' | 'red'
type?: 'standard' | 'outlined' | 'transparent' type?: 'standard' | 'outlined' | 'transparent'

View File

@@ -58,9 +58,10 @@
ref="terminalRef" ref="terminalRef"
class="min-h-0 flex-1" class="min-h-0 flex-1"
:show-input="resolvedShowInput" :show-input="resolvedShowInput"
:disable-input="resolvedDisableInput" :disable-input="resolvedInputDisabled"
:fullscreen="isFullscreen" :fullscreen="isFullscreen"
:empty-state-type="ctx.emptyStateType" :empty-state-type="ctx.emptyStateType"
:loading="resolvedLoading"
@command="handleCommand" @command="handleCommand"
@ready="handleTerminalReady" @ready="handleTerminalReady"
/> />
@@ -206,6 +207,15 @@ const resolvedDisableInput = computed(() => {
return isRef(v) ? v.value : v return isRef(v) ? v.value : v
}) })
// needs historical log start/end flags on ws to be properly useful
const resolvedLoading = computed(() => {
const v = ctx.loading
if (!v) return false
return v.value
})
const resolvedInputDisabled = computed(() => resolvedDisableInput.value || resolvedLoading.value)
const resolvedShareDisabled = computed(() => { const resolvedShareDisabled = computed(() => {
const v = ctx.shareDisabled const v = ctx.shareDisabled
if (!v) return false if (!v) return false
@@ -237,6 +247,11 @@ function rewriteFiltered() {
const term = terminalRef.value?.terminal const term = terminalRef.value?.terminal
if (!term) return if (!term) return
const lines = ctx.logLines.value const lines = ctx.logLines.value
if (resolvedLoading.value && lines.length === 0 && isLiveSource.value) {
terminalRef.value?.clearEmptyState()
lastWrittenIndex = 0
return
}
if (lines.length === 0 && isLiveSource.value) { if (lines.length === 0 && isLiveSource.value) {
writeEmptyState() writeEmptyState()
return return
@@ -271,6 +286,12 @@ watch(ctx.logLines, (lines, oldLines) => {
if (!term) return if (!term) return
if (lines.length === 0 && isLiveSource.value) { if (lines.length === 0 && isLiveSource.value) {
if (resolvedLoading.value) {
terminalRef.value?.clearEmptyState()
lastWrittenIndex = 0
return
}
writeEmptyState() writeEmptyState()
return return
} }
@@ -312,6 +333,12 @@ watch(searchQuery, () => {
}, 200) }, 200)
}) })
watch(resolvedLoading, (loading) => {
if (!loading) {
rewriteFiltered()
}
})
function handleCommand(cmd: string) { function handleCommand(cmd: string) {
ctx.sendCommand?.(cmd) ctx.sendCommand?.(cmd)
} }

View File

@@ -1,12 +1,13 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router' import { onBeforeRouteLeave } from 'vue-router'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { import {
injectAppBackup, injectAppBackup,
injectModrinthClient, injectModrinthClient,
injectModrinthServerContext, injectModrinthServerContext,
injectNotificationManager, injectNotificationManager,
} from '#ui/providers/' } from '#ui/providers'
export function useInlineBackup(backupName: string | (() => string)) { export function useInlineBackup(backupName: string | (() => string)) {
const serverCtx = injectModrinthServerContext(null) const serverCtx = injectModrinthServerContext(null)
@@ -60,110 +61,65 @@ export function useInlineBackup(backupName: string | (() => string)) {
const client = injectModrinthClient() const client = injectModrinthClient()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { serverId, worldId, backupsState, markBackupCancelled } = serverCtx const { serverId, worldId } = serverCtx
const isBackingUp = ref(false) const { activeOperationByBackupId, backups, hasActiveCreate, invalidate } = useServerBackupsQueue(
computed(() => serverId),
worldId,
)
const createdBackupId = ref<string | null>(null)
const pendingCreate = ref(false)
const backupFailed = ref(false) const backupFailed = ref(false)
const backupComplete = ref(false) const backupComplete = ref(false)
const backupCancelled = ref(false) const backupCancelled = ref(false)
const isCancelling = ref(false) const isCancelling = ref(false)
const createdBackupId = ref<string | null>(null)
const externalBackupInProgress = computed(() => { const myBackup = computed(() =>
for (const [id, entry] of backupsState.entries()) { createdBackupId.value ? backups.value.find((b) => b.id === createdBackupId.value) : undefined,
if (id !== createdBackupId.value && entry.create?.state === 'ongoing') return true )
} const myActiveOp = computed(() =>
return false createdBackupId.value ? activeOperationByBackupId.value.get(createdBackupId.value) : undefined,
})
// Watch backupsState for websocket progress events from Kyros
watch(
() => {
if (!createdBackupId.value) return null
return backupsState.get(createdBackupId.value)
},
(entry) => {
if (!entry?.create) return
if (entry.create.state === 'done') {
stopPolling()
isBackingUp.value = false
backupComplete.value = true
} else if (entry.create.state === 'cancelled') {
stopPolling()
isBackingUp.value = false
isCancelling.value = false
backupCancelled.value = true
} else if (entry.create.state === 'failed') {
stopPolling()
isBackingUp.value = false
backupFailed.value = true
}
},
{ deep: true },
) )
// Fallback: poll the REST API in case websocket events don't arrive const isBackingUp = computed(
let pollTimer: ReturnType<typeof setInterval> | null = null () =>
!backupComplete.value &&
!backupFailed.value &&
!backupCancelled.value &&
(!!createdBackupId.value || pendingCreate.value),
)
function stopPolling() { const externalBackupInProgress = computed(() => hasActiveCreate.value && !myActiveOp.value)
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function pollBackupStatus(backupId: string) { watch(
if (!isBackingUp.value) { myBackup,
stopPolling() (b) => {
return if (!createdBackupId.value || !b) return
} if (b.status === 'done') backupComplete.value = true
else if (b.status === 'error' || b.status === 'timed_out') backupFailed.value = true
try { },
const backup = await client.archon.backups_v1.get(serverId, worldId.value!, backupId) { immediate: true },
const isTerminal = )
backup.status === 'done' || backup.status === 'error' || backup.status === 'timed_out'
if (isTerminal) {
stopPolling()
if (!isBackingUp.value) return
if (backup.status === 'error' || backup.status === 'timed_out') {
isBackingUp.value = false
backupFailed.value = true
} else {
isBackingUp.value = false
backupComplete.value = true
}
}
} catch {
stopPolling()
isBackingUp.value = false
backupFailed.value = true
}
}
async function startBackup() { async function startBackup() {
if (!worldId.value) return if (!worldId.value) return
const name = typeof backupName === 'function' ? backupName() : backupName const name = typeof backupName === 'function' ? backupName() : backupName
isBackingUp.value = true
backupFailed.value = false backupFailed.value = false
backupComplete.value = false backupComplete.value = false
backupCancelled.value = false backupCancelled.value = false
isCancelling.value = false isCancelling.value = false
createdBackupId.value = null createdBackupId.value = null
pendingCreate.value = true
try { try {
const { id } = await client.archon.backups_v1.create(serverId, worldId.value, { name }) const { id } = await client.archon.backups_queue_v1.create(serverId, worldId.value, { name })
createdBackupId.value = id createdBackupId.value = id
await invalidate()
stopPolling()
pollTimer = setInterval(() => pollBackupStatus(id), 3000)
} catch (error) { } catch (error) {
isBackingUp.value = false
backupFailed.value = true backupFailed.value = true
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
const isRateLimit = message.includes('429') const isRateLimit = message.includes('429')
addNotification({ addNotification({
@@ -171,6 +127,8 @@ export function useInlineBackup(backupName: string | (() => string)) {
title: 'Error creating backup', title: 'Error creating backup',
text: isRateLimit ? "You're creating backups too fast." : message, text: isRateLimit ? "You're creating backups too fast." : message,
}) })
} finally {
pendingCreate.value = false
} }
} }
@@ -178,23 +136,19 @@ export function useInlineBackup(backupName: string | (() => string)) {
if (!worldId.value || !createdBackupId.value || !isBackingUp.value) return if (!worldId.value || !createdBackupId.value || !isBackingUp.value) return
isCancelling.value = true isCancelling.value = true
stopPolling()
markBackupCancelled(createdBackupId.value)
try { try {
await client.archon.backups_v1.delete(serverId, worldId.value, createdBackupId.value) await client.archon.backups_v1.delete(serverId, worldId.value, createdBackupId.value)
isBackingUp.value = false
isCancelling.value = false
backupCancelled.value = true backupCancelled.value = true
isCancelling.value = false
await invalidate()
addNotification({ addNotification({
type: 'info', type: 'info',
title: 'Backup cancelled', title: 'Backup cancelled',
text: 'The backup has been cancelled. You can create a new one or proceed without a backup.', text: 'The backup has been cancelled. You can create a new one or proceed without a backup.',
}) })
} catch { } catch {
isBackingUp.value = false
isCancelling.value = false
backupFailed.value = true backupFailed.value = true
isCancelling.value = false
} }
} }
@@ -216,7 +170,6 @@ export function useInlineBackup(backupName: string | (() => string)) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload)
stopPolling()
}) })
onBeforeRouteLeave(() => { onBeforeRouteLeave(() => {

View File

@@ -17,16 +17,12 @@ import {
ShareIcon, ShareIcon,
TextCursorInputIcon, TextCursorInputIcon,
TrashIcon, TrashIcon,
UploadIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue' import EmptyState from '#ui/components/base/EmptyState.vue'
import OverflowMenu from '#ui/components/base/OverflowMenu.vue' import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import StyledInput from '#ui/components/base/StyledInput.vue' import StyledInput from '#ui/components/base/StyledInput.vue'
import { useDebugLogger } from '#ui/composables/debug-logger' import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -53,6 +49,15 @@ import type { ContentCardTableItem, ContentItem } from './types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const debug = useDebugLogger('ContentPageLayout') const debug = useDebugLogger('ContentPageLayout')
const props = withDefaults(
defineProps<{
bottomPadding?: boolean
}>(),
{
bottomPadding: true,
},
)
const messages = defineMessages({ const messages = defineMessages({
loadingContent: { loadingContent: {
id: 'content.page-layout.loading', id: 'content.page-layout.loading',
@@ -134,18 +139,10 @@ const messages = defineMessages({
id: 'content.page-layout.share.label', id: 'content.page-layout.share.label',
defaultMessage: 'Share', defaultMessage: 'Share',
}, },
uploadingFiles: {
id: 'content.page-layout.uploading-files',
defaultMessage: 'Uploading files ({completed}/{total})',
},
sortByLabel: { sortByLabel: {
id: 'content.page-layout.sort.label', id: 'content.page-layout.sort.label',
defaultMessage: 'Sort by {mode}', defaultMessage: 'Sort by {mode}',
}, },
busyDescription: {
id: 'content.page-layout.busy-description',
defaultMessage: 'Please wait for the operation to complete before editing content.',
},
pleaseWait: { pleaseWait: {
id: 'content.page-layout.please-wait', id: 'content.page-layout.please-wait',
defaultMessage: 'Please wait', defaultMessage: 'Please wait',
@@ -154,12 +151,6 @@ const messages = defineMessages({
const ctx = injectContentManager() const ctx = injectContentManager()
const uploadOverallProgress = computed(() => {
const state = ctx.uploadState?.value
if (!state || !state.isUploading || state.totalFiles === 0) return 0
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
})
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest' type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
const sortMode = ref<SortMode>('alphabetical-asc') const sortMode = ref<SortMode>('alphabetical-asc')
@@ -502,7 +493,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</script> </script>
<template> <template>
<div class="flex flex-col gap-4 pb-6"> <div class="flex flex-col gap-4" :class="{ 'pb-6': props.bottomPadding }">
<template v-if="!ctx.loading.value"> <template v-if="!ctx.loading.value">
<div <div
v-if="ctx.error.value" v-if="ctx.error.value"
@@ -518,11 +509,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</div> </div>
<template v-else> <template v-else>
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
<template #header>{{ ctx.busyMessage.value }}</template>
{{ formatMessage(messages.busyDescription) }}
</Admonition>
<ContentModpackCard <ContentModpackCard
v-if="ctx.modpack.value" v-if="ctx.modpack.value"
:project="ctx.modpack.value.project" :project="ctx.modpack.value.project"
@@ -550,43 +536,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
@dismiss-content-hint="ctx.dismissContentHint?.()" @dismiss-content-hint="ctx.dismissContentHint?.()"
/> />
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
aria-live="polite"
>
<Admonition
v-if="ctx.uploadState?.value?.isUploading"
type="info"
show-actions-underneath
>
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
{{
formatMessage(messages.uploadingFiles, {
completed: ctx.uploadState?.value?.completedFiles ?? 0,
total: ctx.uploadState?.value?.totalFiles ?? 0,
})
}}
</template>
<span class="text-secondary">
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
Math.round(uploadOverallProgress * 100)
}}%)
</span>
<template #actions>
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
</template>
</Admonition>
</Transition>
<template v-if="ctx.items.value.length > 0"> <template v-if="ctx.items.value.length > 0">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast"> <span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">

View File

@@ -25,8 +25,6 @@ export interface ContentModpackData {
disabledText?: string disabledText?: string
} }
export type { UploadState } from '@modrinth/api-client'
export interface ContentManagerContext { export interface ContentManagerContext {
// Data // Data
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]> items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
@@ -79,9 +77,6 @@ export interface ContentManagerContext {
// Share support (optional — when undefined, share button becomes hidden entirely) // Share support (optional — when undefined, share button becomes hidden entirely)
shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
// Upload progress (optional)
uploadState?: Ref<UploadState> | ComputedRef<UploadState>
// Bulk operation guard — set by layout, checked by providers to suppress refreshes // Bulk operation guard — set by layout, checked by providers to suppress refreshes
isBulkOperating?: Ref<boolean> isBulkOperating?: Ref<boolean>

View File

@@ -1,113 +0,0 @@
<template>
<TransitionGroup
name="fs-op"
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<Admonition
v-for="op in activeOperations"
:key="`fs-op-${op.op}-${op.src}`"
:type="op.state === 'done' ? 'success' : op.state?.startsWith('fail') ? 'critical' : 'info'"
class="mb-4"
>
<template #icon="{ iconClass }">
<PackageOpenIcon :class="iconClass" />
</template>
<template #header>
{{
formatMessage(messages.extracting, {
source: op.src.includes('https://') ? formatMessage(messages.modpackFromUrl) : op.src,
})
}}
<span v-if="op.state === 'done'" class="font-normal text-green">
{{ formatMessage(commonMessages.doneLabel) }}</span
>
<span v-else-if="op.state?.startsWith('fail')" class="font-normal text-red">
{{ formatMessage(messages.failed) }}</span
>
</template>
<span class="text-secondary">
{{
formatMessage(messages.extracted, {
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
})
}}
<template v-if="'current_file' in op && op.current_file">
{{ op.current_file?.split('/')?.pop() }}
</template>
</span>
<template v-if="op.id" #top-right-actions>
<ButtonStyled
v-if="op.state !== 'done' && !op.state?.startsWith('fail')"
type="outlined"
color="blue"
>
<button class="!border" @click="ctx.dismissOperation(op.id!, 'cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="op.state === 'done' || op.state?.startsWith('fail')"
circular
type="transparent"
hover-color-fill="background"
:color="op.state === 'done' ? 'green' : 'red'"
>
<button @click="ctx.dismissOperation(op.id!, 'dismiss')">
<XIcon />
</button>
</ButtonStyled>
</template>
<template #progress>
<ProgressBar
:progress="'progress' in op ? (op.progress ?? 0) : 0"
:max="1"
:color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
full-width
/>
</template>
</Admonition>
</TransitionGroup>
</template>
<script setup lang="ts">
import { PackageOpenIcon, XIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const messages = defineMessages({
extracting: {
id: 'files.operations.extracting',
defaultMessage: 'Extracting {source}',
},
modpackFromUrl: {
id: 'files.operations.modpack-from-url',
defaultMessage: 'modpack from URL',
},
failed: {
id: 'files.operations.failed',
defaultMessage: 'Failed',
},
extracted: {
id: 'files.operations.extracted',
defaultMessage: '{size} extracted',
},
})
const ctx = injectModrinthServerContext()
const activeOperations = ctx.activeOperations
</script>

View File

@@ -32,10 +32,6 @@
> >
</FileContextMenu> </FileContextMenu>
<div v-if="!(ctx.loading.value && items.length === 0)" class="contents"> <div v-if="!(ctx.loading.value && items.length === 0)" class="contents">
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
<template #header>{{ ctx.busyWarning.value }}</template>
{{ formatMessage(messages.busyWarning) }}
</Admonition>
<div class="relative flex w-full flex-col"> <div class="relative flex w-full flex-col">
<div class="relative isolate flex w-full flex-col gap-4"> <div class="relative isolate flex w-full flex-col gap-4">
<FileNavbar <FileNavbar
@@ -210,7 +206,6 @@ import {
import type { Component } from 'vue' import type { Component } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue' import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -244,10 +239,6 @@ import type { FileContextMenuOption, FileItem } from './types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const messages = defineMessages({ const messages = defineMessages({
busyWarning: {
id: 'files.layout.busy-warning',
defaultMessage: 'File operations are disabled while the operation is in progress.',
},
emptyFolderTitle: { emptyFolderTitle: {
id: 'files.layout.empty-folder-title', id: 'files.layout.empty-folder-title',
defaultMessage: 'This folder is empty', defaultMessage: 'This folder is empty',

View File

@@ -0,0 +1,74 @@
import type { Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref, watch } from 'vue'
type BackupQueueBackup = Archon.BackupsQueue.v1.BackupQueueBackup
export function useBackupsSelection(
visibleBackups: Ref<BackupQueueBackup[]>,
displayOrderedBackups: ComputedRef<BackupQueueBackup[]>,
) {
const selectedIds = ref<Set<string>>(new Set())
watch(visibleBackups, () => {
const ids = new Set(visibleBackups.value.map((b) => b.id))
const next = new Set<string>()
for (const id of selectedIds.value) {
if (ids.has(id)) next.add(id)
}
if (next.size !== selectedIds.value.size) {
selectedIds.value = next
}
})
function toggleSelection(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function selectAll() {
selectedIds.value = new Set(visibleBackups.value.map((b) => b.id))
}
function deselectAll() {
selectedIds.value = new Set()
}
function toggleSelectAll() {
if (allSelected.value) deselectAll()
else selectAll()
}
const allSelected = computed(
() =>
visibleBackups.value.length > 0 &&
visibleBackups.value.every((b) => selectedIds.value.has(b.id)),
)
const someSelected = computed(() => {
const vis = visibleBackups.value
if (vis.length === 0) return false
let n = 0
for (const b of vis) {
if (selectedIds.value.has(b.id)) n++
}
return n > 0 && n < vis.length
})
const selectedBackups = computed(() =>
displayOrderedBackups.value.filter((b) => selectedIds.value.has(b.id)),
)
return {
selectedIds,
toggleSelection,
selectAll,
deselectAll,
toggleSelectAll,
allSelected,
someSelected,
selectedBackups,
}
}

View File

@@ -28,13 +28,33 @@
<div v-else key="content" class="contents"> <div v-else key="content" class="contents">
<ReadyTransition :pending="backupsReadyPending"> <ReadyTransition :pending="backupsReadyPending">
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" /> <BackupCreateModal ref="createBackupModal" :backups="completedBackups" />
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" /> <BackupRenameModal ref="renameBackupModal" :backups="completedBackups" />
<BackupRestoreModal ref="restoreBackupModal" /> <BackupRestoreModal ref="restoreBackupModal" />
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" /> <BackupDeleteModal
ref="deleteBackupModal"
@delete="deleteBackup"
@bulk-delete="bulkDelete"
/>
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between"> <div
<span class="text-2xl font-semibold text-contrast">Backups</span> v-if="completedBackups.length"
class="mb-2 flex flex-wrap items-center justify-between gap-4"
>
<div class="flex min-w-0 flex-wrap items-center gap-4">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
:label="formatMessage(messages.selectAll)"
class="shrink-0"
label-class="text-secondary font-semibold"
@update:model-value="toggleSelectAll"
/>
<div class="hidden h-6 w-px bg-surface-5 sm:block" />
<FilterPills v-model="selectedFilters" :options="filterPillOptions">
<template #all>{{ formatMessage(commonMessages.allProjectType) }}</template>
</FilterPills>
</div>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button <button
v-tooltip="backupCreationDisabled" v-tooltip="backupCreationDisabled"
@@ -42,80 +62,157 @@
@click="showCreateModel" @click="showCreateModel"
> >
<PlusIcon class="size-5" /> <PlusIcon class="size-5" />
Create backup {{ formatMessage(messages.createBackup) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<template v-if="backupsData"> <div class="flex w-full flex-col gap-1.5">
<div class="flex w-full flex-col gap-1.5"> <div
<Transition name="fade" mode="out-in"> v-if="groupedBackups.length === 0"
<div class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
v-if="groupedBackups.length === 0" >
key="empty" <EmptyState
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary" v-if="completedBackups.length === 0"
> type="empty-inbox"
<EmptyState :heading="formatMessage(messages.emptyHeading)"
type="empty-inbox" :description="formatMessage(messages.emptyDescription)"
heading="No backups yet" >
description="Create your first backup" <template #actions>
> <ButtonStyled color="brand">
<template #actions> <button
<ButtonStyled color="brand"> v-tooltip="backupCreationDisabled"
<button :disabled="!!backupCreationDisabled"
v-tooltip="backupCreationDisabled" class="mx-auto w-min"
:disabled="!!backupCreationDisabled" @click="showCreateModel"
class="w-min mx-auto" >
@click="showCreateModel" <PlusIcon class="size-5" />
> {{ formatMessage(messages.createBackup) }}
<PlusIcon class="size-5" /> </button>
Create backup </ButtonStyled>
</button> </template>
</ButtonStyled> </EmptyState>
</template> <EmptyState
</EmptyState> v-else
</div> type="empty-inbox"
:heading="formatMessage(messages.filteredEmptyHeading)"
<div v-else key="list" class="flex flex-col gap-1.5"> :description="formatMessage(messages.filteredEmptyDescription)"
<template v-for="group in groupedBackups" :key="group.label"> >
<div class="flex items-center gap-2"> <template #actions>
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" /> <ButtonStyled type="outlined">
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span> <button class="!border !border-surface-4" @click="clearBackupFilters">
</div> {{ formatMessage(messages.clearFilters) }}
</button>
<div class="flex gap-2"> </ButtonStyled>
<div class="flex w-5 justify-center"> </template>
<div class="h-full w-px bg-surface-5" /> </EmptyState>
</div>
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
<BackupItem
v-for="backup in group.backups"
:key="`backup-${backup.id}`"
:backup="backup"
:restore-disabled="backupRestoreDisabled"
:kyros-url="server.node?.instance"
:jwt="server.node?.token"
:show-copy-id-action="showCopyIdAction"
:show-debug-info="showDebugInfo"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@delete="
(skipConfirmation?: boolean) =>
skipConfirmation
? deleteBackup(backup)
: deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</TransitionGroup>
</div>
</template>
</div>
</Transition>
</div> </div>
</template>
<div v-else class="flex flex-col gap-3">
<template v-for="group in groupedBackups" :key="group.label">
<div class="flex items-center gap-2">
<div class="flex w-5 shrink-0 items-center justify-center">
<component :is="group.icon" v-if="group.icon" class="size-5" />
</div>
<span class="text-lg font-semibold leading-5 text-contrast">{{ group.label }}</span>
</div>
<TransitionGroup name="list" tag="div" class="flex flex-col">
<div
v-for="(backup, backupIndex) in group.backups"
:key="`backup-${backup.id}`"
class="flex gap-2"
>
<div class="flex w-5 flex-col items-center">
<div
class="w-px flex-1 bg-surface-5"
:class="{ '-mt-1.5': backupIndex === 0 }"
/>
<Checkbox
:model-value="selectedIds.has(backup.id)"
:description="formatMessage(messages.selectBackupAria, { name: backup.name })"
class="shrink-0"
@update:model-value="toggleSelection(backup.id)"
/>
<div class="w-px flex-1 bg-surface-5" />
</div>
<BackupItem
class="my-1.5 min-w-0 flex-1"
:backup="backup"
:selected="selectedIds.has(backup.id)"
:restore-disabled="backupRestoreDisabled"
:kyros-url="server.node?.instance"
:jwt="server.node?.token"
:show-copy-id-action="showCopyIdAction"
:show-debug-info="showDebugInfo"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@delete="
(skipConfirmation?: boolean) =>
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
/>
</div>
</TransitionGroup>
</template>
</div>
</div>
<FloatingActionBar
:shown="selectedIds.size > 0 || isBulkOperating"
:aria-label="
formatMessage(messages.bulkBarAriaLabel, {
count: isBulkOperating ? bulkTotal : selectedIds.size,
})
"
>
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold tabular-nums text-contrast">
{{
formatMessage(messages.selectedCount, {
count: isBulkOperating ? bulkTotal : selectedIds.size,
})
}}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button
type="button"
:disabled="isBulkOperating"
:class="{ 'pointer-events-none opacity-60': isBulkOperating }"
@click="deselectAll"
>
{{ formatMessage(commonMessages.clearButton) }}
</button>
</ButtonStyled>
</div>
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
<ButtonStyled type="transparent" color="red" hover-color-fill="background">
<button type="button" @click="confirmBulkDelete">
<TrashIcon />
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</button>
</ButtonStyled>
</div>
<div v-else class="ml-auto flex items-center" aria-live="polite">
<span class="px-4 py-2.5 text-base font-semibold tabular-nums text-secondary">
{{ formatMessage(messages.bulkDeleting, { total: bulkTotal }) }}
</span>
</div>
<div v-if="isBulkOperating" class="absolute bottom-0 left-0 right-0 h-1">
<div
class="animate-indeterminate h-full rounded-l-full bg-brand"
role="progressbar"
:aria-valuemin="0"
:aria-valuemax="bulkTotal"
style="box-shadow: 0px -2px 4px 0px rgba(27, 217, 106, 0.1)"
/>
</div>
</FloatingActionBar>
<div <div
class="over-the-top-download-animation" class="over-the-top-download-animation"
@@ -142,35 +239,102 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon } from '@modrinth/assets' import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { Component } from 'vue' import type { Component } from 'vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import EmptyState from '#ui/components/base/EmptyState.vue' import EmptyState from '#ui/components/base/EmptyState.vue'
import FilterPills, { type FilterPillOption } from '#ui/components/base/FilterPills.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue' import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import BackupCreateModal from '#ui/components/servers/backups/BackupCreateModal.vue' import BackupCreateModal from '#ui/components/servers/backups/BackupCreateModal.vue'
import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue' import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue'
import BackupItem from '#ui/components/servers/backups/BackupItem.vue' import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue' import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue' import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
import { useReadyState } from '#ui/composables' import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useVIntl } from '#ui/composables/i18n' import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
import { import {
injectModrinthClient, injectModrinthClient,
injectModrinthServerContext, injectModrinthServerContext,
injectNotificationManager, injectNotificationManager,
} from '#ui/providers' } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { useBackupsSelection } from './backups-selection'
const messages = defineMessages({
selectAll: {
id: 'servers.backups.toolbar.select-all',
defaultMessage: 'Select all',
},
selectBackupAria: {
id: 'servers.backups.select-backup-aria',
defaultMessage: 'Select backup {name}',
},
filterManual: {
id: 'servers.backups.toolbar.filter-manual',
defaultMessage: 'Manual',
},
filterAuto: {
id: 'servers.backups.toolbar.filter-auto',
defaultMessage: 'Auto',
},
selectedCount: {
id: 'servers.backups.bulk-bar.selected-count',
defaultMessage: '{count, plural, one {# backup selected} other {# backups selected}}',
},
bulkBarAriaLabel: {
id: 'servers.backups.bulk-bar.aria-label',
defaultMessage:
'{count, plural, one {Bulk actions for one selected backup} other {Bulk actions for # selected backups}}',
},
createBackup: {
id: 'servers.backups.toolbar.create-backup',
defaultMessage: 'Create backup',
},
emptyHeading: {
id: 'servers.backups.empty.heading',
defaultMessage: 'No backups yet',
},
emptyDescription: {
id: 'servers.backups.empty.description',
defaultMessage: 'Create your first backup',
},
filteredEmptyHeading: {
id: 'servers.backups.filtered-empty.heading',
defaultMessage: 'No backups match',
},
filteredEmptyDescription: {
id: 'servers.backups.filtered-empty.description',
defaultMessage: 'Try a different filter or clear filters to see all backups.',
},
clearFilters: {
id: 'servers.backups.filtered-empty.clear-filters',
defaultMessage: 'Clear filters',
},
bulkDeleting: {
id: 'servers.backups.bulk-bar.deleting',
defaultMessage: 'Deleting {total, plural, one {# backup} other {# backups}}...',
},
})
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const filterPillOptions = computed<FilterPillOption[]>(() => [
{ id: 'manual', label: formatMessage(messages.filterManual) },
{ id: 'auto', label: formatMessage(messages.filterAuto) },
])
const client = injectModrinthClient() const client = injectModrinthClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { server, worldId, backupsState, markBackupCancelled, busyReasons } = const { server, worldId, busyReasons } = injectModrinthServerContext()
injectModrinthServerContext()
const props = defineProps<{ const props = defineProps<{
isServerRunning: boolean isServerRunning: boolean
@@ -183,81 +347,82 @@ const serverId = route.params.id as string
defineEmits(['onDownload']) defineEmits(['onDownload'])
const backupsQueryKey = ['backups', 'list', serverId] const { backups, invalidate, hasActiveCreate, hasActiveRestore, query } = useServerBackupsQueue(
const { computed(() => serverId),
data: backupsData, worldId,
isLoading, )
error,
refetch, const error = computed(() => {
} = useQuery({ const err = query.error.value
queryKey: backupsQueryKey, return err instanceof Error ? err : err ? new Error(String(err)) : null
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!), })
enabled: computed(() => worldId.value !== null), const refetch = () => query.refetch()
/** Until world exists we cannot fetch; `isLoading` is false while the query is disabled, which would flash empty state. */
const backupsReadyPending = computed(
() => !worldId.value || (query.data.value === undefined && !query.error.value),
)
const selectedFilters = ref<string[]>([])
const completedBackups = computed(() => backups.value.filter((backup) => backup.status === 'done'))
const filteredBackups = computed(() => {
const f = selectedFilters.value
if (f.length === 0 || f.length === 2) {
return completedBackups.value
}
const wantAuto = f.includes('auto')
return completedBackups.value.filter((b) => b.automated === wantAuto)
}) })
const backupsReadyPending = useReadyState({ isLoading, data: backupsData }) /** Completed backups with a snapshot: queue API schedules deletion. */
const deleteQueueMutation = useMutation({
const deleteMutation = useMutation({
mutationFn: (backupId: string) => mutationFn: (backupId: string) =>
client.archon.backups_v1.delete(serverId, worldId.value!, backupId), client.archon.backups_queue_v1.delete(serverId, worldId.value!, backupId),
onSuccess: (_data, backupId) => { onSuccess: async () => {
markBackupCancelled(backupId) await invalidate()
backupsState.delete(backupId) await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}, },
}) })
const retryMutation = useMutation({ /** In-progress / incomplete backups: legacy cancel + delete path. */
const deleteLegacyMutation = useMutation({
mutationFn: (backupId: string) => mutationFn: (backupId: string) =>
client.archon.backups_v1.retry(serverId, worldId.value!, backupId), client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }), onSuccess: async () => {
await invalidate()
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
},
}) })
const backups = computed(() => { /** Bulk delete via queue API — handles both completed and in-progress backups (cancels the latter). */
if (!backupsData.value) return [] const deleteManyMutation = useMutation({
mutationFn: (backupIds: string[]) =>
const merged = backupsData.value.map((backup) => { client.archon.backups_queue_v1.deleteMany(serverId, worldId.value!, backupIds),
const progressState = backupsState.get(backup.id) onSuccess: async () => {
if (progressState) { await invalidate()
const hasOngoingTask = Object.values(progressState).some((task) => task?.state === 'ongoing') await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
const hasCompletedTask = Object.values(progressState).some((task) => task?.state === 'done') },
return {
...backup,
task: {
...backup.task,
...progressState,
},
status: hasOngoingTask
? ('in_progress' as const)
: hasCompletedTask
? ('done' as const)
: backup.status,
ongoing: hasOngoingTask || (backup.ongoing && !hasCompletedTask),
}
}
return backup
})
return merged.sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
}) })
type BackupGroup = { type BackupGroup = {
label: string label: string
icon: Component | null icon: Component | null
backups: Archon.Backups.v1.Backup[] backups: Archon.BackupsQueue.v1.BackupQueueBackup[]
} }
const groupedBackups = computed((): BackupGroup[] => { const groupedBackups = computed((): BackupGroup[] => {
if (!backups.value.length) return [] if (!filteredBackups.value.length) return []
const now = dayjs() const now = dayjs()
const groups: BackupGroup[] = [] const groups: BackupGroup[] = []
const addToGroup = (label: string, icon: Component | null, backup: Archon.Backups.v1.Backup) => { const addToGroup = (
label: string,
icon: Component | null,
backup: Archon.BackupsQueue.v1.BackupQueueBackup,
) => {
let group = groups.find((g) => g.label === label) let group = groups.find((g) => g.label === label)
if (!group) { if (!group) {
group = { label, icon, backups: [] } group = { label, icon, backups: [] }
@@ -266,7 +431,7 @@ const groupedBackups = computed((): BackupGroup[] => {
group.backups.push(backup) group.backups.push(backup)
} }
for (const backup of backups.value) { for (const backup of filteredBackups.value) {
const created = dayjs(backup.created_at) const created = dayjs(backup.created_at)
const diffMinutes = now.diff(created, 'minute') const diffMinutes = now.diff(created, 'minute')
const isToday = created.isSame(now, 'day') const isToday = created.isSame(now, 'day')
@@ -289,6 +454,20 @@ const groupedBackups = computed((): BackupGroup[] => {
return groups return groups
}) })
const displayOrderedBackups = computed(() => groupedBackups.value.flatMap((g) => g.backups))
const {
selectedIds,
toggleSelection,
deselectAll,
toggleSelectAll,
allSelected,
someSelected,
selectedBackups,
} = useBackupsSelection(filteredBackups, displayOrderedBackups)
const { isBulkOperating, bulkTotal } = useBulkOperation()
const overTheTopDownloadAnimation = ref() const overTheTopDownloadAnimation = ref()
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>() const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>() const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
@@ -302,13 +481,16 @@ const backupRestoreDisabled = computed(() => {
if (busyReasons.value.length > 0) { if (busyReasons.value.length > 0) {
return formatMessage(busyReasons.value[0].reason) return formatMessage(busyReasons.value[0].reason)
} }
if (hasActiveCreate.value || hasActiveRestore.value) {
return 'A backup operation is already queued or in progress'
}
return undefined return undefined
}) })
const backupCreationDisabled = computed(() => { const backupCreationDisabled = computed(() => {
const quota = server.value.backup_quota const quota = server.value.backup_quota
if (quota !== undefined) { if (quota !== undefined) {
const usedCount = backupsData.value?.length ?? server.value.used_backup_quota ?? 0 const usedCount = backups.value.length ?? server.value.used_backup_quota ?? 0
if (usedCount >= quota) { if (usedCount >= quota) {
return `All ${quota} of your backup slots are in use` return `All ${quota} of your backup slots are in use`
} }
@@ -316,9 +498,8 @@ const backupCreationDisabled = computed(() => {
if (busyReasons.value.length > 0) { if (busyReasons.value.length > 0) {
return formatMessage(busyReasons.value[0].reason) return formatMessage(busyReasons.value[0].reason)
} }
// also check for active backups, combining REST data with WS overlay if (hasActiveCreate.value) {
if (backups.value.some((b) => b.status === 'in_progress' || b.status === 'pending')) { return 'A backup is already queued or in progress'
return 'A backup is already in progress'
} }
return undefined return undefined
}) })
@@ -327,20 +508,46 @@ const showCreateModel = () => {
createBackupModal.value?.show() createBackupModal.value?.show()
} }
function clearBackupFilters() {
selectedFilters.value = []
}
function confirmBulkDelete() {
if (!selectedBackups.value.length) return
deleteBackupModal.value?.showBulk(selectedBackups.value)
}
async function bulkDelete(toRemove: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
if (!toRemove.length) return
isBulkOperating.value = true
bulkTotal.value = toRemove.length
try {
await deleteManyMutation.mutateAsync(toRemove.map((b) => b.id))
} catch (err) {
addNotification({
type: 'error',
title: `Failed to delete ${toRemove.length} backup${toRemove.length === 1 ? '' : 's'}`,
text: err instanceof Error ? err.message : String(err),
})
} finally {
deselectAll()
isBulkOperating.value = false
bulkTotal.value = 0
}
}
function triggerDownloadAnimation() { function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500) setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
} }
const retryBackup = (backupId: string) => { function useQueueDeleteFor(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
retryMutation.mutate(backupId, { return backup.status === 'done'
onError: (err) => {
console.error('Failed to retry backup:', err)
},
})
} }
function deleteBackup(backup?: Archon.Backups.v1.Backup) { function deleteBackup(backup?: Archon.BackupsQueue.v1.BackupQueueBackup) {
if (!backup) { if (!backup) {
addNotification({ addNotification({
type: 'error', type: 'error',
@@ -350,7 +557,9 @@ function deleteBackup(backup?: Archon.Backups.v1.Backup) {
return return
} }
deleteMutation.mutate(backup.id, { const mutation = useQueueDeleteFor(backup) ? deleteQueueMutation : deleteLegacyMutation
mutation.mutate(backup.id, {
onError: (err) => { onError: (err) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
addNotification({ addNotification({
@@ -396,6 +605,21 @@ function deleteBackup(backup?: Archon.Backups.v1.Backup) {
transition: transform 200ms ease-in-out; transition: transform 200ms ease-in-out;
} }
@keyframes indeterminate {
0% {
width: 20%;
margin-left: -20%;
}
100% {
width: 60%;
margin-left: 100%;
}
}
.animate-indeterminate {
animation: indeterminate 1.5s ease-in-out infinite;
}
.over-the-top-download-animation { .over-the-top-download-animation {
position: fixed; position: fixed;
z-index: 100; z-index: 100;

View File

@@ -2,11 +2,10 @@
import type { Archon, Labrinth } from '@modrinth/api-client' import type { Archon, Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon } from '@modrinth/assets' import { ClipboardCopyIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, ref } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue' import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { useReadyState } from '#ui/composables' import { useReadyState } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { import {
@@ -22,10 +21,7 @@ import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/Co
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue' import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue' import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue'
import ContentPageLayout from '../../../shared/content-tab/layout.vue' import ContentPageLayout from '../../../shared/content-tab/layout.vue'
import type { import type { ContentModpackData } from '../../../shared/content-tab/providers/content-manager'
ContentModpackData,
UploadState,
} from '../../../shared/content-tab/providers/content-manager'
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager' import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
import type { import type {
ContentItem, ContentItem,
@@ -85,20 +81,9 @@ const messages = defineMessages({
}, },
}) })
const leaveMessages = defineMessages({
uploadInProgress: {
id: 'instances.confirm-leave-modal.upload-in-progress',
defaultMessage: 'Upload in progress',
},
leavePageBody: {
id: 'instances.confirm-leave-modal.body',
defaultMessage:
'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
},
})
const client = injectModrinthClient() const client = injectModrinthClient()
const { server, worldId, busyReasons, isSyncingContent } = injectModrinthServerContext() const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
injectModrinthServerContext()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal() const { openServerSettings, browseServerContent } = injectServerSettingsModal()
const route = useRoute() const route = useRoute()
@@ -352,57 +337,10 @@ async function handleBulkDisable(items: ContentItem[]) {
} }
} }
const uploadState = ref<UploadState>({
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
})
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>() const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>() const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>() const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>()
let activeUploadCancel: (() => void) | null = null
const isUploading = computed(() => uploadState.value.isUploading)
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isUploading.value) {
e.preventDefault()
return ''
}
}
if (typeof window !== 'undefined') {
watch(isUploading, (uploading) => {
if (uploading) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (isUploading.value) {
const shouldLeave = (await confirmLeaveModal.value?.prompt()) ?? false
if (shouldLeave) {
activeUploadCancel?.()
}
return shouldLeave
}
return true
})
}
const updatingProject = ref<ContentItem | null>(null) const updatingProject = ref<ContentItem | null>(null)
const updatingModpack = ref(false) const updatingModpack = ref(false)
const loadingChangelog = ref(false) const loadingChangelog = ref(false)
@@ -486,7 +424,7 @@ function handleUploadFiles() {
uploadState.value.totalBytes = p.total uploadState.value.totalBytes = p.total
}, },
}) })
activeUploadCancel = () => handle.cancel() cancelUpload.value = () => handle.cancel()
try { try {
await handle.promise await handle.promise
@@ -500,7 +438,7 @@ function handleUploadFiles() {
text: err instanceof Error ? err.message : undefined, text: err instanceof Error ? err.message : undefined,
}) })
} finally { } finally {
activeUploadCancel = null cancelUpload.value = null
uploadState.value = { uploadState.value = {
isUploading: false, isUploading: false,
currentFileName: null, currentFileName: null,
@@ -875,7 +813,6 @@ provideContentManager({
}, },
browse: handleBrowseContent, browse: handleBrowseContent,
uploadFiles: handleUploadFiles, uploadFiles: handleUploadFiles,
uploadState,
deletionContext: 'server', deletionContext: 'server',
hasUpdateSupport: true, hasUpdateSupport: true,
updateItem: handleUpdateItem, updateItem: handleUpdateItem,
@@ -911,7 +848,7 @@ provideContentManager({
<template> <template>
<ReadyTransition :pending="contentReadyPending"> <ReadyTransition :pending="contentReadyPending">
<ContentPageLayout> <ContentPageLayout :bottom-padding="false">
<template #modals> <template #modals>
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" /> <ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
<ModpackContentModal <ModpackContentModal
@@ -968,10 +905,4 @@ provideContentManager({
@confirm="handleModpackUpdateConfirm" @confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel" @cancel="handleModpackUpdateCancel"
/> />
<ConfirmLeaveModal
ref="confirmLeaveModal"
:header="formatMessage(leaveMessages.uploadInProgress)"
:body="formatMessage(leaveMessages.leavePageBody)"
admonition-type="critical"
/>
</template> </template>

View File

@@ -89,6 +89,7 @@
<template v-else> <template v-else>
<div <div
v-if="!showEmptyState"
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row" class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
> >
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast"> <h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
@@ -131,7 +132,7 @@
</div> </div>
<div <div
v-else-if="serverList.length === 0 && !isPollingForNewServers" v-else-if="showEmptyState"
key="empty" key="empty"
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]" class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
> >
@@ -559,6 +560,11 @@ const serverList = computed<Archon.Servers.v0.Server[]>(() => {
return serverResponse.value.servers return serverResponse.value.servers
}) })
const showEmptyState = computed(
() =>
!showServersListLoading.value && serverList.value.length === 0 && !isPollingForNewServers.value,
)
const searchInput = ref('') const searchInput = ref('')
const fuse = computed(() => { const fuse = computed(() => {

View File

@@ -117,7 +117,12 @@ provideConsoleManager({
}, },
showCommandInput: true, showCommandInput: true,
disableCommandInput: computed(() => serverPowerState.value !== 'running'), disableCommandInput: computed(() => serverPowerState.value !== 'running'),
loading: computed(() => !isConnected.value || isWsAuthIncorrect.value), loading: computed(
() =>
!isConnected.value ||
modrinthServersConsole.isInitialLogHydrating.value ||
isWsAuthIncorrect.value,
),
onClear: async () => { onClear: async () => {
modrinthServersConsole.clear() modrinthServersConsole.clear()
try { try {

View File

@@ -99,7 +99,7 @@
<div <div
v-else-if="serverData" v-else-if="serverData"
data-pyro-server-manager-root data-pyro-server-manager-root
class="experimental-styles-within relative mx-auto pb-6 box-border flex min-h-[calc(100svh-100px)] w-full min-w-0 flex-col gap-4 px-6 transition-all duration-300" class="experimental-styles-within relative mx-auto box-border flex w-full min-w-0 flex-col gap-4 px-6 transition-all duration-300"
:style="{ :style="{
'--server-bg-image': serverImage '--server-bg-image': serverImage
? `url(${serverImage})` ? `url(${serverImage})`
@@ -107,9 +107,7 @@
}" }"
:class="[ :class="[
'server-panel-' + revealState, 'server-panel-' + revealState,
{ isNuxt ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
'max-w-[1280px]': isNuxt,
},
]" ]"
> >
<template v-if="revealState !== 'pending' || isOnboarding"> <template v-if="revealState !== 'pending' || isOnboarding">
@@ -309,66 +307,13 @@
Hang on, we're reconnecting to your server. Hang on, we're reconnecting to your server.
</div> </div>
<Transition <ServerPanelAdmonitions
enter-active-class="transition-all duration-300 ease-out overflow-hidden" class="mb-4"
enter-from-class="opacity-0 max-h-0" :sync-progress="syncProgress"
enter-to-class="opacity-100 max-h-40" :content-error="contentError"
leave-active-class="transition-all duration-200 ease-in overflow-hidden" :server-image="serverImage"
leave-from-class="opacity-100 max-h-40" @content-retry="handleContentRetry"
leave-to-class="opacity-0 max-h-0" />
>
<InstallingBanner
v-if="
(serverData.status === 'installing' || isSyncingContent || contentError) &&
syncProgress?.phase !== 'Analyzing'
"
data-pyro-server-installing
class="mb-4"
:progress="syncProgress"
:content-error="contentError"
@retry="handleContentRetry"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
</template>
</InstallingBanner>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<Admonition v-if="uploadState.isUploading" type="info" class="mb-4">
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
Uploading files ({{ uploadState.completedFiles }}/{{ uploadState.totalFiles }})
<span v-if="uploadState.currentFileName" class="font-normal text-secondary">
— {{ uploadState.currentFileName }}
</span>
</template>
<span class="text-secondary">
{{ formatBytes(uploadState.uploadedBytes) }} /
{{ formatBytes(uploadState.totalBytes) }} ({{
Math.round(uploadOverallProgress * 100)
}}%)
</span>
<template v-if="cancelUpload" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" @click="cancelUpload?.()">Cancel</button>
</ButtonStyled>
</template>
<template #progress>
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
</template>
</Admonition>
</Transition>
<FileOperationAdmonitions class="mb-4" />
<BackupProgressAdmonitions class="mb-4" />
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" /> <slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
</div> </div>
</template> </template>
@@ -417,11 +362,9 @@ import {
SettingsIcon, SettingsIcon,
TransferIcon, TransferIcon,
TriangleAlertIcon, TriangleAlertIcon,
UploadIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import type { Stats } from '@modrinth/utils' import type { Stats } from '@modrinth/utils'
import { formatBytes } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage, useTimeoutFn } from '@vueuse/core' import { useStorage, useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
@@ -429,16 +372,12 @@ import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue' import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue'
import NavTabs from '#ui/components/base/NavTabs.vue' import NavTabs from '#ui/components/base/NavTabs.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import ServerNotice from '#ui/components/base/ServerNotice.vue' import ServerNotice from '#ui/components/base/ServerNotice.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue' import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import BackupProgressAdmonitions from '#ui/components/servers/backups/BackupProgressAdmonitions.vue' import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue'
import { ServerIcon } from '#ui/components/servers/icons'
import InstallingBanner from '#ui/components/servers/InstallingBanner.vue'
import MedalServerCountdown from '#ui/components/servers/marketing/MedalServerCountdown.vue' import MedalServerCountdown from '#ui/components/servers/marketing/MedalServerCountdown.vue'
import { import {
PanelServerActionButton, PanelServerActionButton,
@@ -455,6 +394,7 @@ import {
useServerProject, useServerProject,
} from '#ui/composables' } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime' import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime'
import type { LogLine } from '#ui/layouts/shared/console' import type { LogLine } from '#ui/layouts/shared/console'
import type { ServerSettingsTabId } from '#ui/layouts/shared/server-settings' import type { ServerSettingsTabId } from '#ui/layouts/shared/server-settings'
@@ -465,7 +405,6 @@ import {
} from '#ui/providers' } from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders' import { formatLoaderLabel } from '#ui/utils/loaders'
import FileOperationAdmonitions from '../../../shared/files-tab/components/FileOperationAdmonitions.vue'
import ServerOnboardingPanelPage from './[id]/onboarding.vue' import ServerOnboardingPanelPage from './[id]/onboarding.vue'
interface Tab { interface Tab {
@@ -618,17 +557,17 @@ const worldId = computed(() => {
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
}) })
const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQueue(
computed(() => props.serverId),
worldId,
)
const { image: serverImage } = useServerImage( const { image: serverImage } = useServerImage(
props.serverId, props.serverId,
computed(() => serverData.value?.upstream ?? null), computed(() => serverData.value?.upstream ?? null),
) )
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null)) const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
const cancelledBackups = new Set<string>()
const markBackupCancelled = (backupId: string) => {
cancelledBackups.add(backupId)
}
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null) const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null) const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null)
const syncProgressActive = ref(false) const syncProgressActive = ref(false)
@@ -686,7 +625,6 @@ const onStateEvent = (data: Archon.Websocket.v0.WSStateEvent) => {
} }
const { const {
backupsState,
cancelUpload, cancelUpload,
cleanupCoreRuntime, cleanupCoreRuntime,
connectSocket, connectSocket,
@@ -704,8 +642,7 @@ const {
worldId, worldId,
server: serverData, server: serverData,
isSyncingContent, isSyncingContent,
markBackupCancelled, extraBusyReasons: backupsBusy,
includeBackupBusyReasons: true,
setDisconnectedOnAuthIncorrect: false, setDisconnectedOnAuthIncorrect: false,
syncUptimeFromState: true, syncUptimeFromState: true,
incrementUptimeLocally: true, incrementUptimeLocally: true,
@@ -713,12 +650,6 @@ const {
onStateEvent, onStateEvent,
}) })
const uploadOverallProgress = computed(() => {
const state = uploadState.value
if (!state.isUploading || state.totalFiles === 0) return 0
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
})
const isUploading = computed(() => uploadState.value.isUploading) const isUploading = computed(() => uploadState.value.isUploading)
function handleBeforeUnload(e: BeforeUnloadEvent) { function handleBeforeUnload(e: BeforeUnloadEvent) {
@@ -981,69 +912,7 @@ async function handleContentRetry() {
} }
const handleBackupProgress = (data: Archon.Websocket.v0.WSBackupProgressEvent) => { const handleBackupProgress = (data: Archon.Websocket.v0.WSBackupProgressEvent) => {
if (data.task === 'file') return handleWsBackupProgress(data)
const backupId = data.id
if (cancelledBackups.has(backupId)) return
const current = backupsState.get(backupId) ?? {}
const currentTaskState = current[data.task]?.state
const isIncomingTerminal =
data.state === 'done' || data.state === 'failed' || data.state === 'cancelled'
if (currentTaskState === data.state && isIncomingTerminal) return
const previousProgress = current[data.task]?.progress
if (currentTaskState !== data.state || previousProgress !== data.progress) {
backupsState.set(backupId, {
...current,
[data.task]: {
progress: data.progress,
state: data.state,
},
})
}
if (isIncomingTerminal) {
const attemptCleanup = (attempt: number = 1) => {
queryClient.invalidateQueries({ queryKey: ['backups', 'list', props.serverId] }).then(() => {
const backupData = queryClient.getQueryData<Archon.Backups.v1.Backup[]>([
'backups',
'list',
props.serverId,
])
const backup = backupData?.find((b) => b.id === backupId)
const isStillActive =
backup && (backup.status === 'in_progress' || backup.status === 'pending')
if (isStillActive && attempt < 6) {
setTimeout(() => attemptCleanup(attempt + 1), 1000 * Math.pow(2, attempt - 1))
return
}
if (isStillActive) {
queryClient.setQueryData<Archon.Backups.v1.Backup[]>(
['backups', 'list', props.serverId],
(old) =>
old?.map((b) => {
if (b.id !== backupId) return b
return {
...b,
status: data.state === 'done' ? ('done' as const) : ('error' as const),
ongoing: false,
interrupted: data.state === 'failed',
}
}),
)
}
backupsState.delete(backupId)
})
}
attemptCleanup()
}
} }
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => { const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
@@ -1455,20 +1324,23 @@ function initializeServer() {
} }
} }
let intercomInitialized = false
const cleanup = () => { const cleanup = () => {
isMounted.value = false isMounted.value = false
saveWsStateToCache() saveWsStateToCache()
shutdown() if (intercomInitialized) {
shutdown()
intercomInitialized = false
}
cleanupCoreRuntime(props.serverId) cleanupCoreRuntime(props.serverId)
isReconnecting.value = false isReconnecting.value = false
isLoading.value = true isLoading.value = true
cancelledBackups.clear()
DOMPurify.removeHook('afterSanitizeAttributes') DOMPurify.removeHook('afterSanitizeAttributes')
} }
@@ -1486,19 +1358,36 @@ onMounted(() => {
}) })
} }
let intercomInitialized = false
const tryInitIntercom = () => { const tryInitIntercom = () => {
if (intercomInitialized) return if (intercomInitialized) return
if (!props.authUser || !props.fetchIntercomToken) return if (!props.authUser || !props.fetchIntercomToken) {
console.debug('[PYROSERVERS][INTERCOM] waiting for auth user and token fetcher', {
hasAuthUser: !!props.authUser,
hasFetchIntercomToken: !!props.fetchIntercomToken,
})
return
}
intercomInitialized = true intercomInitialized = true
console.debug('[PYROSERVERS][INTERCOM] initializing secure support chat')
props props
.fetchIntercomToken() .fetchIntercomToken()
.then(({ token }) => { .then(({ token }) => {
console.debug('[PYROSERVERS][INTERCOM] fetched messenger JWT, booting widget')
Intercom({ Intercom({
app_id: props.intercomAppId!, app_id: props.intercomAppId!,
intercom_user_jwt: token, intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24, session_duration: 1000 * 60 * 60 * 24,
}) })
window.setTimeout(() => {
const hasWidget = !!document.querySelector(
'.intercom-lightweight-app, #intercom-container, #intercom-frame',
)
if (!hasWidget) {
console.warn(
'[PYROSERVERS][INTERCOM] boot completed but no Intercom widget was detected',
)
}
}, 2500)
}) })
.catch((error) => { .catch((error) => {
intercomInitialized = false intercomInitialized = false

View File

@@ -452,9 +452,6 @@
"content.page-layout.upload-files": { "content.page-layout.upload-files": {
"defaultMessage": "Upload files" "defaultMessage": "Upload files"
}, },
"content.page-layout.uploading-files": {
"defaultMessage": "Uploading files ({completed}/{total})"
},
"content.selection-bar.all-already-disabled": { "content.selection-bar.all-already-disabled": {
"defaultMessage": "All selected content is already disabled" "defaultMessage": "All selected content is already disabled"
}, },
@@ -974,14 +971,20 @@
"files.navbar.upload-from-zip-url": { "files.navbar.upload-from-zip-url": {
"defaultMessage": "Upload from .zip URL" "defaultMessage": "Upload from .zip URL"
}, },
"files.operations.current-file": {
"defaultMessage": "Current file: {file}"
},
"files.operations.extracted": { "files.operations.extracted": {
"defaultMessage": "{size} extracted" "defaultMessage": "{size} extracted"
}, },
"files.operations.extracting": { "files.operations.extracting": {
"defaultMessage": "Extracting {source}" "defaultMessage": "Extracting {source}"
}, },
"files.operations.failed": { "files.operations.extracting-completed": {
"defaultMessage": "Failed" "defaultMessage": "Extracting {source} finished"
},
"files.operations.extracting-failed": {
"defaultMessage": "Extracting {source} failed"
}, },
"files.operations.modpack-from-url": { "files.operations.modpack-from-url": {
"defaultMessage": "modpack from URL" "defaultMessage": "modpack from URL"
@@ -1487,12 +1490,6 @@
"instance.worlds.game_mode.unknown": { "instance.worlds.game_mode.unknown": {
"defaultMessage": "Unknown game mode" "defaultMessage": "Unknown game mode"
}, },
"instances.confirm-leave-modal.body": {
"defaultMessage": "Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost."
},
"instances.confirm-leave-modal.upload-in-progress": {
"defaultMessage": "Upload in progress"
},
"instances.content-install.compatible-count": { "instances.content-install.compatible-count": {
"defaultMessage": "{count} compatible {count, plural, one {instance} other {instances}}" "defaultMessage": "{count} compatible {count, plural, one {instance} other {instances}}"
}, },
@@ -2837,6 +2834,9 @@
"project.visibility.unlisted-by-staff": { "project.visibility.unlisted-by-staff": {
"defaultMessage": "Unlisted by staff" "defaultMessage": "Unlisted by staff"
}, },
"s.bg": {
"defaultMessage": "Background task running"
},
"search.filter.locked.default": { "search.filter.locked.default": {
"defaultMessage": "Filter locked" "defaultMessage": "Filter locked"
}, },
@@ -2924,6 +2924,21 @@
"search.server_content_type.vanilla": { "search.server_content_type.vanilla": {
"defaultMessage": "Vanilla" "defaultMessage": "Vanilla"
}, },
"servers.admonitions.background-task-running": {
"defaultMessage": "Background task running"
},
"servers.backups.admonition.backup-cancelled.description": {
"defaultMessage": "Backup {backupName} was cancelled."
},
"servers.backups.admonition.backup-cancelled.title": {
"defaultMessage": "Backup cancelled"
},
"servers.backups.admonition.backup-completed.description": {
"defaultMessage": "{backupName} finished successfully."
},
"servers.backups.admonition.backup-completed.title": {
"defaultMessage": "Backup finished"
},
"servers.backups.admonition.backup-failed.description": { "servers.backups.admonition.backup-failed.description": {
"defaultMessage": "Something went wrong while creating {backupName}. Please try again or contact support if the issue continues." "defaultMessage": "Something went wrong while creating {backupName}. Please try again or contact support if the issue continues."
}, },
@@ -2936,6 +2951,12 @@
"servers.backups.admonition.backup-queued.title": { "servers.backups.admonition.backup-queued.title": {
"defaultMessage": "Backup queued" "defaultMessage": "Backup queued"
}, },
"servers.backups.admonition.backup-timed-out.description": {
"defaultMessage": "Creating {backupName} timed out. You can try again or contact support if the issue continues."
},
"servers.backups.admonition.backup-timed-out.title": {
"defaultMessage": "Backup timed out"
},
"servers.backups.admonition.creating-backup.description": { "servers.backups.admonition.creating-backup.description": {
"defaultMessage": "Saving world data and server configuration for {backupName}. This can take a few minutes." "defaultMessage": "Saving world data and server configuration for {backupName}. This can take a few minutes."
}, },
@@ -2945,23 +2966,35 @@
"servers.backups.admonition.fallback-name": { "servers.backups.admonition.fallback-name": {
"defaultMessage": "Your backup" "defaultMessage": "Your backup"
}, },
"servers.backups.admonition.restore-cancelled.description": {
"defaultMessage": "Restoring from {backupName} was cancelled."
},
"servers.backups.admonition.restore-cancelled.title": {
"defaultMessage": "Restore cancelled"
},
"servers.backups.admonition.restore-failed.description": { "servers.backups.admonition.restore-failed.description": {
"defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues." "defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues."
}, },
"servers.backups.admonition.restore-failed.title": { "servers.backups.admonition.restore-failed.title": {
"defaultMessage": "Restoring from backup failed" "defaultMessage": "Restore failed"
}, },
"servers.backups.admonition.restore-queued.description": { "servers.backups.admonition.restore-queued.description": {
"defaultMessage": "Restoring from {backupName} is queued and will start shortly." "defaultMessage": "Restoring from {backupName} is queued and will start shortly."
}, },
"servers.backups.admonition.restore-queued.title": { "servers.backups.admonition.restore-queued.title": {
"defaultMessage": "Restoring from backup queued" "defaultMessage": "Restore queued"
}, },
"servers.backups.admonition.restore-successful.description": { "servers.backups.admonition.restore-successful.description": {
"defaultMessage": "Your server has been restored to {backupName} and is ready to start." "defaultMessage": "Your server has been restored to {backupName} and is ready to start."
}, },
"servers.backups.admonition.restore-successful.title": { "servers.backups.admonition.restore-successful.title": {
"defaultMessage": "Restoring from backup successful" "defaultMessage": "Restore finished"
},
"servers.backups.admonition.restore-timed-out.description": {
"defaultMessage": "Restoring from {backupName} timed out. You can try again or contact support if the issue continues."
},
"servers.backups.admonition.restore-timed-out.title": {
"defaultMessage": "Restore timed out"
}, },
"servers.backups.admonition.restoring-backup.description": { "servers.backups.admonition.restoring-backup.description": {
"defaultMessage": "Restoring your server from {backupName}. This may take a couple of minutes." "defaultMessage": "Restoring your server from {backupName}. This may take a couple of minutes."
@@ -2969,18 +3002,51 @@
"servers.backups.admonition.restoring-backup.title": { "servers.backups.admonition.restoring-backup.title": {
"defaultMessage": "Restoring from backup" "defaultMessage": "Restoring from backup"
}, },
"servers.backups.bulk-bar.aria-label": {
"defaultMessage": "{count, plural, one {Bulk actions for one selected backup} other {Bulk actions for # selected backups}}"
},
"servers.backups.bulk-bar.deleting": {
"defaultMessage": "Deleting {total, plural, one {# backup} other {# backups}}..."
},
"servers.backups.bulk-bar.selected-count": {
"defaultMessage": "{count, plural, one {# backup selected} other {# backups selected}}"
},
"servers.backups.delete-modal.admonition-body": {
"defaultMessage": "Once deleted, {count, plural, one {this backup cannot} other {these backups cannot}} be recovered. Deletion is permanent."
},
"servers.backups.delete-modal.admonition-header": {
"defaultMessage": "Deletion warning"
},
"servers.backups.delete-modal.backups-label": {
"defaultMessage": "{count, plural, one {Backup} other {Backups ({count})}}"
},
"servers.backups.delete-modal.confirm": {
"defaultMessage": "Delete {count, plural, one {backup} other {# backups}}"
},
"servers.backups.delete-modal.header": {
"defaultMessage": "Delete {count, plural, one {backup} other {backups}}"
},
"servers.backups.empty.description": {
"defaultMessage": "Create your first backup"
},
"servers.backups.empty.heading": {
"defaultMessage": "No backups yet"
},
"servers.backups.filtered-empty.clear-filters": {
"defaultMessage": "Clear filters"
},
"servers.backups.filtered-empty.description": {
"defaultMessage": "Try a different filter or clear filters to see all backups."
},
"servers.backups.filtered-empty.heading": {
"defaultMessage": "No backups match"
},
"servers.backups.item.auto": { "servers.backups.item.auto": {
"defaultMessage": "Auto" "defaultMessage": "Auto"
}, },
"servers.backups.item.backup-schedule": { "servers.backups.item.backup-schedule": {
"defaultMessage": "Backup schedule" "defaultMessage": "Backup schedule"
}, },
"servers.backups.item.failed-to-create-backup": {
"defaultMessage": "Failed to create backup"
},
"servers.backups.item.failed-to-restore-backup": {
"defaultMessage": "Failed to restore from backup"
},
"servers.backups.item.manual-backup": { "servers.backups.item.manual-backup": {
"defaultMessage": "Manual backup" "defaultMessage": "Manual backup"
}, },
@@ -2990,6 +3056,21 @@
"servers.backups.item.restore": { "servers.backups.item.restore": {
"defaultMessage": "Restore" "defaultMessage": "Restore"
}, },
"servers.backups.select-backup-aria": {
"defaultMessage": "Select backup {name}"
},
"servers.backups.toolbar.create-backup": {
"defaultMessage": "Create backup"
},
"servers.backups.toolbar.filter-auto": {
"defaultMessage": "Auto"
},
"servers.backups.toolbar.filter-manual": {
"defaultMessage": "Manual"
},
"servers.backups.toolbar.select-all": {
"defaultMessage": "Select all"
},
"servers.busy.backup-creating": { "servers.busy.backup-creating": {
"defaultMessage": "Backup creation in progress" "defaultMessage": "Backup creation in progress"
}, },
@@ -3919,5 +4000,11 @@
}, },
"ui.confirm-leave-modal.title": { "ui.confirm-leave-modal.title": {
"defaultMessage": "Leave page?" "defaultMessage": "Leave page?"
},
"ui.stacked-admonitions.alert-count": {
"defaultMessage": "{count, plural, one {# alert} other {# alerts}}"
},
"ui.stacked-admonitions.dismiss-all": {
"defaultMessage": "Dismiss all"
} }
} }

View File

@@ -1,6 +1,6 @@
import type { Archon, UploadState } from '@modrinth/api-client' import type { Archon, UploadState } from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils' import type { Stats } from '@modrinth/utils'
import type { ComputedRef, Reactive, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import type { MessageDescriptor } from '#ui/composables/i18n' import type { MessageDescriptor } from '#ui/composables/i18n'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types' import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
@@ -11,19 +11,6 @@ export interface BusyReason {
reason: MessageDescriptor reason: MessageDescriptor
} }
export type BackupTaskState = {
progress: number
state: Archon.Backups.v1.BackupState
}
export type BackupProgressEntry = {
file?: BackupTaskState
create?: BackupTaskState
restore?: BackupTaskState
}
export type BackupsState = Map<string, BackupProgressEntry>
export interface FilesystemAuth { export interface FilesystemAuth {
url: string url: string
token: string token: string
@@ -42,8 +29,6 @@ export interface ModrinthServerContext {
readonly isServerRunning: ComputedRef<boolean> readonly isServerRunning: ComputedRef<boolean>
readonly stats: Ref<Stats> readonly stats: Ref<Stats>
readonly uptimeSeconds: Ref<number> readonly uptimeSeconds: Ref<number>
readonly backupsState: Reactive<BackupsState>
markBackupCancelled: (backupId: string) => void
// Content sync state // Content sync state
readonly isSyncingContent: Ref<boolean> readonly isSyncingContent: Ref<boolean>

View File

@@ -1,8 +1,8 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Admonition from '../../components/base/Admonition.vue' import Admonition from '../../components/base/Admonition.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue' import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ProgressBar from '../../components/base/ProgressBar.vue'
const meta = { const meta = {
title: 'Base/Admonition', title: 'Base/Admonition',
@@ -57,37 +57,56 @@ export const Dismissible: Story = {
}, },
} }
export const HeaderWithTimestamp: Story = {
render: () => ({
components: { Admonition },
setup() {
const t = ref(Date.now() - 3600_000)
return { t }
},
template: /*html*/ `
<Admonition
type="info"
header="Creating backup"
:timestamp="t"
>
Saving world data for my-world.
</Admonition>
`,
}),
}
export const WithTopRightActions: Story = { export const WithTopRightActions: Story = {
render: () => ({ render: () => ({
components: { Admonition, ButtonStyled }, components: { Admonition, ButtonStyled },
template: /*html*/ ` template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;"> <div style="display: flex; flex-direction: column; gap: 1rem;">
<Admonition type="info" header="Uploading files (2/5)"> <Admonition
type="info"
header="Uploading files (2/5)"
:dismissible="false"
>
Uploading server files... Uploading server files...
<template #top-right-actions> <template #top-right-actions>
<ButtonStyled type="outlined" color="blue"> <ButtonStyled type="outlined" color="blue">
<button class="!border">Cancel</button> <button class="!border" type="button">Cancel</button>
</ButtonStyled> </ButtonStyled>
</template> </template>
</Admonition> </Admonition>
<Admonition type="critical" header="Extraction failed"> <Admonition
type="critical"
header="Extraction failed"
:dismissible="true"
>
Something went wrong while extracting the archive. Something went wrong while extracting the archive.
<template #top-right-actions> <template #top-right-actions>
<ButtonStyled color="red"> <ButtonStyled color="red">
<button>Retry</button> <button type="button">Retry</button>
</ButtonStyled>
<ButtonStyled circular type="transparent" hover-color-fill="background" color="red">
<button>✕</button>
</ButtonStyled> </ButtonStyled>
</template> </template>
</Admonition> </Admonition>
<Admonition type="success" header="Extraction complete"> <Admonition type="success" header="Extraction complete" :dismissible="true">
All files have been extracted successfully. All files have been extracted successfully.
<template #top-right-actions>
<ButtonStyled circular type="transparent" hover-color-fill="background" color="green">
<button>✕</button>
</ButtonStyled>
</template>
</Admonition> </Admonition>
</div> </div>
`, `,
@@ -96,41 +115,55 @@ export const WithTopRightActions: Story = {
export const WithProgressBar: Story = { export const WithProgressBar: Story = {
render: () => ({ render: () => ({
components: { Admonition, ButtonStyled, ProgressBar }, components: { Admonition, ButtonStyled },
template: /*html*/ ` template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;"> <div style="display: flex; flex-direction: column; gap: 1rem;">
<Admonition type="info" header="Uploading files (2/5)"> <Admonition
type="info"
header="Uploading files (2/5)"
:dismissible="false"
:progress="0.45"
progress-color="blue"
>
128 KB / 1.2 MB (45%) 128 KB / 1.2 MB (45%)
<template #top-right-actions> <template #top-right-actions>
<ButtonStyled type="outlined" color="blue"> <ButtonStyled type="outlined" color="blue">
<button class="!border">Cancel</button> <button class="!border" type="button">Cancel</button>
</ButtonStyled> </ButtonStyled>
</template> </template>
<template #progress>
<ProgressBar :progress="0.45" :max="1" color="blue" full-width />
</template>
</Admonition> </Admonition>
<Admonition type="info" header="Extracting modpack.zip"> <Admonition
type="info"
header="Extracting modpack.zip"
:dismissible="false"
:progress="0.7"
progress-color="blue"
>
24 MB extracted — config/settings.yml 24 MB extracted — config/settings.yml
<template #top-right-actions> <template #top-right-actions>
<ButtonStyled type="outlined" color="blue"> <ButtonStyled type="outlined" color="blue">
<button class="!border">Cancel</button> <button class="!border" type="button">Cancel</button>
</ButtonStyled> </ButtonStyled>
</template> </template>
<template #progress>
<ProgressBar :progress="0.7" :max="1" color="blue" full-width />
</template>
</Admonition> </Admonition>
<Admonition type="success" header="Extraction complete — Done"> <Admonition
type="success"
header="Extraction complete — Done"
:dismissible="true"
:progress="1"
progress-color="green"
>
56 MB extracted 56 MB extracted
<template #top-right-actions> </Admonition>
<ButtonStyled circular type="transparent" hover-color-fill="background" color="green"> <Admonition
<button>✕</button> type="info"
</ButtonStyled> header="Waiting for upload"
</template> :dismissible="false"
<template #progress> :progress="0"
<ProgressBar :progress="1" :max="1" color="green" full-width /> progress-color="blue"
</template> waiting
>
Queued and waiting for available bandwidth.
</Admonition> </Admonition>
</div> </div>
`, `,

View File

@@ -40,6 +40,14 @@ export const Indeterminate: Story = {
}, },
} }
export const LabelClass: Story = {
args: {
label: 'Custom label class',
labelClass: 'text-brand font-bold',
modelValue: true,
},
}
export const AllStates: StoryObj = { export const AllStates: StoryObj = {
render: () => ({ render: () => ({
components: { Checkbox }, components: { Checkbox },

View File

@@ -0,0 +1,507 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Admonition from '../../components/base/Admonition.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import StackedAdmonitionsRaw, {
type StackedAdmonitionItem,
} from '../../components/base/StackedAdmonitions.vue'
// The generic type signature of StackedAdmonitions breaks Storybook's Meta
// inference and Vue's components record type. Cast to `any` for story wiring;
// runtime behavior is unchanged.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const StackedAdmonitions = StackedAdmonitionsRaw as any
interface DemoItem extends StackedAdmonitionItem {
header: string
body: string
}
const meta: Meta = {
title: 'Base/StackedAdmonitions',
component: StackedAdmonitions,
}
export default meta
type Story = StoryObj
const initialItems: DemoItem[] = [
{
id: 'backup-failed',
type: 'critical',
header: 'Backup failed',
body: 'Something went wrong while creating your backup. Try again or contact support.',
},
{
id: 'storage-nearly-full',
type: 'warning',
header: 'Storage nearly full',
body: 'Your server is using 92% of available storage.',
},
{
id: 'scheduled-maintenance',
type: 'info',
header: 'Scheduled maintenance',
body: 'Routine maintenance will begin in 30 minutes.',
},
]
const variedHeightItems: DemoItem[] = [
initialItems[0],
{
id: 'storage-nearly-full',
type: 'warning',
header: 'Storage nearly full',
body: 'Your server is using 92% of available storage. Large world backups, installation artifacts, and cached uploads are all contributing here, so this card is intentionally taller to exercise the collapse animation between mixed-height banners.',
},
initialItems[2],
]
const completedBackupItem: DemoItem = {
id: 'backup-completed',
type: 'success',
header: 'Backup completed',
body: 'Backup 38298832 finished successfully.',
}
export const Empty: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
template: /* html */ `
<div style="min-height: 4rem;">
<StackedAdmonitions :items="[]">
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" />
</template>
</StackedAdmonitions>
<p style="color: var(--color-secondary); margin-top: 1rem;">
Nothing should render above this line.
</p>
</div>
`,
}),
}
export const SingleItem: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>([completedBackupItem])
function dismiss(id: string) {
items.value = items.value.filter((i) => i.id !== id)
}
function reset() {
items.value = [completedBackupItem]
}
return { items, dismiss, reset }
},
template: /* html */ `
<div>
<button
type="button"
style="margin-bottom: 0.75rem; padding: 0.25rem 0.75rem; border-radius: 0.5rem; background: var(--color-button-bg); color: var(--color-contrast);"
@click="reset"
>
Reset
</button>
<StackedAdmonitions :items="items">
<template #item="{ item }">
<Admonition
:type="item.type"
:header="item.header"
:body="item.body"
dismissible
@dismiss="dismiss(item.id)"
/>
</template>
</StackedAdmonitions>
</div>
`,
}),
}
export const TwoItems: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>(initialItems.slice(0, 2))
return { items }
},
template: /* html */ `
<StackedAdmonitions :items="items">
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" />
</template>
</StackedAdmonitions>
`,
}),
}
export const FiveItems: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>([
...initialItems,
{
id: 'update-available',
type: 'success',
header: 'Update available',
body: 'A new version of your server software is ready to install.',
},
{
id: 'memory-pressure',
type: 'warning',
header: 'High memory usage',
body: 'Your server is using 89% of allocated memory.',
},
])
return { items }
},
template: /* html */ `
<StackedAdmonitions :items="items">
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" />
</template>
</StackedAdmonitions>
`,
}),
}
export const MixedTypes: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>([
{
id: 'critical',
type: 'critical',
header: 'Critical admonition',
body: 'Red background placeholder when not front.',
},
{
id: 'warning',
type: 'warning',
header: 'Warning admonition',
body: 'Orange background placeholder when not front.',
},
{
id: 'info',
type: 'info',
header: 'Info admonition',
body: 'Blue background placeholder when not front.',
},
{
id: 'success',
type: 'success',
header: 'Success admonition',
body: 'Green background placeholder when not front.',
},
])
return { items }
},
template: /* html */ `
<StackedAdmonitions :items="items">
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" />
</template>
</StackedAdmonitions>
`,
}),
}
export const VariedHeights: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>(variedHeightItems)
const expanded = ref(false)
let nextId = 1
function addAlert() {
const type = ['info', 'warning', 'critical', 'success'][nextId % 4] as DemoItem['type']
items.value = [
...items.value,
{
id: `new-alert-${nextId}`,
type,
header: `New alert ${nextId}`,
body:
nextId % 2 === 0
? 'A short dynamically added alert.'
: 'A dynamically added alert with a longer body so the stack can exercise measurement updates when new mixed-height items enter.',
},
]
nextId += 1
}
function reset() {
items.value = variedHeightItems
expanded.value = false
nextId = 1
}
return { items, expanded, addAlert, reset }
},
template: /* html */ `
<div>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<button
type="button"
style="padding: 0.25rem 0.75rem; border-radius: 0.5rem; background: var(--color-button-bg); color: var(--color-contrast);"
@click="addAlert"
>
Add alert
</button>
<button
type="button"
style="padding: 0.25rem 0.75rem; border-radius: 0.5rem; background: var(--color-button-bg); color: var(--color-contrast);"
@click="reset"
>
Reset
</button>
</div>
<StackedAdmonitions
:items="items"
:expanded="expanded"
@update:expanded="expanded = $event"
>
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" />
</template>
</StackedAdmonitions>
</div>
`,
}),
}
export const ForceExpanded: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>(variedHeightItems)
const expanded = ref(true)
return { items, expanded }
},
template: /* html */ `
<StackedAdmonitions :items="items" :expanded="expanded" @update:expanded="expanded = $event">
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" />
</template>
</StackedAdmonitions>
`,
}),
}
export const DismissIndividual: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>([...initialItems])
function dismiss(id: string) {
items.value = items.value.filter((i) => i.id !== id)
}
function reset() {
items.value = [...initialItems]
}
return { items, dismiss, reset }
},
template: /* html */ `
<div>
<button
type="button"
style="margin-bottom: 0.75rem; padding: 0.25rem 0.75rem; border-radius: 0.5rem; background: var(--color-button-bg); color: var(--color-contrast);"
@click="reset"
>
Reset
</button>
<StackedAdmonitions :items="items">
<template #item="{ item }">
<Admonition
:type="item.type"
:header="item.header"
:body="item.body"
dismissible
@dismiss="dismiss(item.id)"
/>
</template>
</StackedAdmonitions>
</div>
`,
}),
}
export const DismissAll: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>([...initialItems])
function dismiss(id: string) {
items.value = items.value.filter((i) => i.id !== id)
}
function dismissAll() {
items.value = []
}
function reset() {
items.value = [...initialItems]
}
return { items, dismiss, dismissAll, reset }
},
template: /* html */ `
<div>
<button
type="button"
style="margin-bottom: 0.75rem; padding: 0.25rem 0.75rem; border-radius: 0.5rem; background: var(--color-button-bg); color: var(--color-contrast);"
@click="reset"
>
Reset
</button>
<StackedAdmonitions :items="items" @dismiss-all="dismissAll">
<template #item="{ item, dismissible }">
<Admonition
:type="item.type"
:header="item.header"
:body="item.body"
:dismissible="dismissible"
@dismiss="dismiss(item.id)"
/>
</template>
</StackedAdmonitions>
</div>
`,
}),
}
interface RichItem extends StackedAdmonitionItem {
header: string
body: string
progress?: number
canRetry?: boolean
canCancel?: boolean
}
export const RichContent: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition, ButtonStyled },
setup() {
const items = ref<RichItem[]>([
{
id: 'upload-1',
type: 'info',
header: 'Uploading files (2/5)',
body: '128 KB / 1.2 MB (45%)',
progress: 0.45,
canCancel: true,
},
{
id: 'extract-1',
type: 'critical',
header: 'Extraction failed',
body: 'Something went wrong while extracting the archive.',
canRetry: true,
},
{
id: 'install-1',
type: 'success',
header: 'Installation complete',
body: 'All files have been installed successfully.',
},
])
function dismiss(id: string) {
items.value = items.value.filter((i) => i.id !== id)
}
function dismissAll() {
items.value = []
}
return { items, dismiss, dismissAll }
},
template: /* html */ `
<StackedAdmonitions :items="items" @dismiss-all="dismissAll">
<template #item="{ item, dismissible }">
<Admonition
:type="item.type"
:header="item.header"
:dismissible="dismissible && !item.canCancel"
:progress="item.progress"
progress-color="blue"
@dismiss="dismiss(item.id)"
>
{{ item.body }}
<template #top-right-actions>
<ButtonStyled v-if="item.canCancel" type="outlined" color="blue">
<button class="!border" type="button" @click="dismiss(item.id)">Cancel</button>
</ButtonStyled>
<ButtonStyled v-if="item.canRetry" color="red">
<button type="button" @click="dismiss(item.id)">Retry</button>
</ButtonStyled>
</template>
</Admonition>
</template>
</StackedAdmonitions>
`,
}),
}
export const KeyboardAndA11y: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const items = ref<DemoItem[]>([...initialItems])
function dismiss(id: string) {
items.value = items.value.filter((i) => i.id !== id)
}
return { items, dismiss }
},
template: /* html */ `
<div>
<p style="color: var(--color-secondary); margin-bottom: 0.75rem; font-size: 0.875rem;">
Tab to the stack and press <kbd>Enter</kbd> or <kbd>Space</kbd> to expand.
While collapsed, only the front card's buttons are focusable (inert on back cards).
</p>
<StackedAdmonitions :items="items">
<template #item="{ item }">
<Admonition
:type="item.type"
:header="item.header"
:body="item.body"
dismissible
@dismiss="dismiss(item.id)"
/>
</template>
</StackedAdmonitions>
</div>
`,
}),
}
export const TwoInstances: Story = {
render: () => ({
components: { StackedAdmonitions, Admonition },
setup() {
const stackA = ref<DemoItem[]>([initialItems[0], initialItems[1]])
const stackB = ref<DemoItem[]>([initialItems[2]])
function dismissA(id: string) {
stackA.value = stackA.value.filter((i) => i.id !== id)
}
function dismissB(id: string) {
stackB.value = stackB.value.filter((i) => i.id !== id)
}
return { stackA, stackB, dismissA, dismissB }
},
template: /* html */ `
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<StackedAdmonitions :items="stackA">
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" dismissible @dismiss="dismissA(item.id)" />
</template>
</StackedAdmonitions>
<StackedAdmonitions :items="stackB">
<template #item="{ item }">
<Admonition :type="item.type" :header="item.header" :body="item.body" dismissible @dismiss="dismissB(item.id)" />
</template>
</StackedAdmonitions>
</div>
`,
}),
}

View File

@@ -0,0 +1,99 @@
import type { Archon } from '@modrinth/api-client'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BackupItem from '../../components/servers/backups/BackupItem.vue'
const meta = {
title: 'Servers/BackupItem',
component: BackupItem,
args: {
preview: false,
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
},
} satisfies Meta<typeof BackupItem>
export default meta
type Story = StoryObj<typeof meta>
function makeBackup(
overrides: Partial<Archon.BackupsQueue.v1.BackupQueueBackup> = {},
): Archon.BackupsQueue.v1.BackupQueueBackup {
return {
id: 'backup-001',
name: 'Backup #5',
created_at: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
automated: false,
status: 'done',
locked: false,
history: [],
...overrides,
}
}
export const Default: Story = {
name: 'Default (manual)',
args: {
backup: makeBackup({ name: 'Base finished!!' }),
},
}
export const Automated: Story = {
name: 'Automated',
args: {
backup: makeBackup({ automated: true, name: 'Backup #2' }),
},
}
export const Preview: Story = {
name: 'Preview (compact, used in delete modal)',
args: {
backup: makeBackup({ name: 'Base finished!!' }),
preview: true,
},
}
export const RestoreDisabled: Story = {
name: 'Restore disabled (server running)',
args: {
backup: makeBackup({ name: 'Backup #5', automated: true }),
restoreDisabled: 'Cannot restore backup while server is running',
},
}
export const CommonStates: Story = {
render: () => ({
components: { BackupItem },
setup() {
const now = new Date(Date.now() - 1000 * 60 * 10).toISOString()
function makeBackup(
overrides: Partial<Archon.BackupsQueue.v1.BackupQueueBackup>,
): Archon.BackupsQueue.v1.BackupQueueBackup {
return {
id: 'backup-001',
name: 'Backup #5',
created_at: now,
automated: false,
status: 'done',
locked: false,
history: [],
...overrides,
}
}
return {
manual: makeBackup({ name: 'Base finished!!' }),
automated: makeBackup({ automated: true, name: 'Backup #2' }),
}
},
template: /* html */ `
<div style="display: flex; flex-direction: column; gap: 0.75rem; max-width: 900px;">
<BackupItem :backup="manual" />
<BackupItem :backup="automated" />
<BackupItem :backup="manual" preview />
</div>
`,
}),
}

View File

@@ -1,7 +1,7 @@
import type { Archon, UploadState } from '@modrinth/api-client' import type { Archon, UploadState } from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils' import type { Stats } from '@modrinth/utils'
import type { Meta, StoryObj } from '@storybook/vue3-vite' import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, reactive, ref } from 'vue' import { computed, ref } from 'vue'
import EditServerIcon from '../../components/servers/edit-server-icon/EditServerIcon.vue' import EditServerIcon from '../../components/servers/edit-server-icon/EditServerIcon.vue'
import { provideModrinthServerContext } from '../../providers' import { provideModrinthServerContext } from '../../providers'
@@ -66,8 +66,6 @@ const meta = {
isServerRunning: computed(() => true), isServerRunning: computed(() => true),
stats, stats,
uptimeSeconds: ref(0), uptimeSeconds: ref(0),
backupsState: reactive(new Map()),
markBackupCancelled: () => {},
isSyncingContent: ref(false), isSyncingContent: ref(false),
busyReasons: computed(() => []), busyReasons: computed(() => []),
fsAuth: ref(null), fsAuth: ref(null),

View File

@@ -23,6 +23,15 @@ export const WithProgress: Story = {
}, },
} }
export const IndeterminateLoaderInstall: Story = {
args: {
progress: {
phase: 'InstallingLoader',
percent: 0,
},
},
}
export const InstallingModpack: Story = { export const InstallingModpack: Story = {
args: { args: {
progress: { progress: {
@@ -97,6 +106,7 @@ export const AllStates: Story = {
template: /*html*/ ` template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;"> <div style="display: flex; flex-direction: column; gap: 1rem;">
<InstallingBanner /> <InstallingBanner />
<InstallingBanner :progress="{ phase: 'InstallingLoader', percent: 0 }" />
<InstallingBanner :progress="{ phase: 'InstallingLoader', percent: 45 }" /> <InstallingBanner :progress="{ phase: 'InstallingLoader', percent: 45 }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'the specified version may be incorrect' }" /> <InstallingBanner :content-error="{ step: 'modloader', description: 'the specified version may be incorrect' }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'this version is not yet supported' }" /> <InstallingBanner :content-error="{ step: 'modloader', description: 'this version is not yet supported' }" />

View File

@@ -0,0 +1,270 @@
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Admonition from '../../components/base/Admonition.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
type AdmonitionType = 'info' | 'warning' | 'critical' | 'success'
type ActionType = 'Cancel' | 'Retry' | 'Dismiss'
type ProgressColor = 'blue' | 'green' | 'red'
interface CopyExample {
title: string
body: string
type: AdmonitionType
action?: ActionType
dismissible?: boolean
progress?: number
progressColor?: ProgressColor
waiting?: boolean
}
interface CopySection {
title: string
items: CopyExample[]
}
const meta = {
title: 'Servers/ServerPanelAdmonitionCopyDraft',
component: Admonition,
parameters: {
layout: 'padded',
},
} satisfies Meta<typeof Admonition>
export default meta
type Story = StoryObj<typeof meta>
const sections: CopySection[] = [
{
title: 'Installation and content sync',
items: [
{
type: 'info',
title: "We're preparing your server",
body: 'Installing platform...',
progress: 45,
progressColor: 'blue',
},
{
type: 'info',
title: "We're preparing your server",
body: 'Installing modpack...',
progress: 72,
progressColor: 'blue',
},
{
type: 'critical',
title: 'Installation failed',
body: 'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
action: 'Retry',
dismissible: true,
},
{
type: 'critical',
title: 'Installation failed',
body: 'This modpack version does not include a downloadable file. It may have been packaged incorrectly.',
action: 'Retry',
dismissible: true,
},
],
},
{
title: 'Uploads and file operations',
items: [
{
type: 'info',
title: 'Uploading resourcepack.zip (1/3)',
body: '20 KB / 100 KB (20%)',
action: 'Cancel',
progress: 0.2,
progressColor: 'blue',
},
{
type: 'info',
title: 'Extracting story-modpack.mrpack',
body: '2 MB extracted. Current file: server.properties',
action: 'Cancel',
progress: 0.35,
progressColor: 'blue',
},
{
type: 'success',
title: 'Extracting story-modpack.mrpack finished',
body: '12 MB extracted',
progress: 1,
progressColor: 'green',
},
{
type: 'critical',
title: 'Extracting story-modpack.mrpack failed',
body: '2 MB extracted',
action: 'Dismiss',
dismissible: true,
progress: 0.35,
progressColor: 'red',
},
],
},
{
title: 'Backup creation',
items: [
{
type: 'info',
title: 'Backup queued',
body: 'World backup is queued and will start shortly.',
action: 'Cancel',
},
{
type: 'info',
title: 'Creating backup',
body: 'Saving world data and server configuration for World backup. This can take a few minutes.',
action: 'Cancel',
progress: 0.42,
progressColor: 'blue',
},
{
type: 'critical',
title: 'Backup failed',
body: 'Something went wrong while creating World backup. Please try again or contact support if the issue continues.',
action: 'Retry',
dismissible: true,
},
{
type: 'success',
title: 'Backup finished',
body: 'World backup finished successfully.',
action: 'Dismiss',
dismissible: true,
},
],
},
{
title: 'Backup restore',
items: [
{
type: 'info',
title: 'Restore queued',
body: 'Restoring from World backup is queued and will start shortly.',
action: 'Cancel',
},
{
type: 'info',
title: 'Restoring from backup',
body: 'Restoring your server from World backup. This may take a couple of minutes.',
action: 'Cancel',
progress: 0.65,
progressColor: 'blue',
},
{
type: 'critical',
title: 'Restore failed',
body: 'Something went wrong while restoring from World backup. Please try again or contact support if the issue continues.',
action: 'Retry',
dismissible: true,
},
{
type: 'success',
title: 'Restore finished',
body: 'Your server has been restored to World backup and is ready to start.',
action: 'Dismiss',
dismissible: true,
},
],
},
{
title: 'Busy states',
items: [
{
type: 'warning',
title: 'Background task running',
body: 'Please wait for the operation to complete before editing content.',
},
{
type: 'warning',
title: 'Background task running',
body: 'File operations are disabled while the operation is in progress.',
},
],
},
]
export const AllCopy: Story = {
render: () => ({
components: { Admonition, ButtonStyled, RotateCounterClockwiseIcon },
setup() {
return { sections }
},
template: /* html */ `
<div style="height: 100vh; overflow-y: auto; padding: 1rem 1rem 4rem 0;">
<div style="display: flex; max-width: 840px; flex-direction: column; gap: 2rem;">
<section v-for="section in sections" :key="section.title">
<h2 style="margin: 0 0 0.75rem; font-size: 1.125rem; font-weight: 700;">
{{ section.title }}
</h2>
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<Admonition
v-for="item in section.items"
:key="item.title + item.body"
:type="item.type"
:header="item.title"
:dismissible="item.dismissible"
:progress="item.progress != null ? (item.progress > 1 ? item.progress / 100 : item.progress) : undefined"
:progress-color="item.progressColor"
:waiting="item.waiting"
>
{{ item.body }}
<template
v-if="
item.action === 'Cancel' ||
item.action === 'Retry'
"
#top-right-actions
>
<ButtonStyled v-if="item.action === 'Cancel'" type="outlined" color="blue">
<button class="!border" type="button">Cancel</button>
</ButtonStyled>
<ButtonStyled
v-else
type="outlined"
color="red"
>
<button class="!border" type="button">
<RotateCounterClockwiseIcon class="size-5" />
Retry
</button>
</ButtonStyled>
</template>
</Admonition>
</div>
</section>
</div>
</div>
`,
}),
}
export const TitleTreatmentExperiment: Story = {
render: () => ({
components: { Admonition, ButtonStyled, RotateCounterClockwiseIcon },
template: /* html */ `
<div style="max-width: 840px;">
<Admonition
type="critical"
header="Backup failed"
dismissible
>
Something went wrong while creating World backup. Please try again or contact support if the issue continues.
<template #top-right-actions>
<ButtonStyled type="outlined" color="red">
<button class="!border" type="button">
<RotateCounterClockwiseIcon class="size-5" />
Retry
</button>
</ButtonStyled>
</template>
</Admonition>
</div>
`,
}),
}

View File

@@ -0,0 +1,116 @@
import type { Archon, UploadState } from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ServerPanelAdmonitions from '../../components/servers/admonitions/ServerPanelAdmonitions.vue'
import { defineMessage } from '../../composables/i18n'
import type { FileOperation } from '../../layouts/shared/files-tab/types'
import { provideModrinthServerContext } from '../../providers'
import type { ModrinthServerContext } from '../../providers/server-context'
const meta = {
title: 'Servers/ServerPanelAdmonitions',
component: ServerPanelAdmonitions,
parameters: {
layout: 'padded',
},
decorators: [
(story) => ({
components: { story },
setup() {
const router = useRouter()
onMounted(() => {
router.replace('/hosting/manage/demo-server/content')
})
const server = ref({
server_id: 'demo-server',
status: 'running',
upstream: null,
} as Archon.Servers.v0.Server)
const stats = ref<Stats>({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
past: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
graph: { cpu: [], ram: [] },
})
const uploadState = ref<UploadState>({
isUploading: true,
currentFileName: 'resourcepack.zip',
currentFileProgress: 0.2,
uploadedBytes: 20_000,
totalBytes: 100_000,
completedFiles: 1,
totalFiles: 3,
})
const fileOp = ref<FileOperation[]>([
{
id: 'fs-op-1',
op: 'extract',
src: 'story-modpack.mrpack',
state: 'running',
progress: 0.35,
bytes_processed: 2_000_000,
},
])
const serverContext: ModrinthServerContext = {
get serverId() {
return 'demo-server'
},
worldId: ref(null),
server,
isConnected: ref(true),
isWsAuthIncorrect: ref(false),
powerState: ref('running'),
powerStateDetails: ref(undefined),
isServerRunning: computed(() => true),
stats,
uptimeSeconds: ref(0),
isSyncingContent: ref(false),
busyReasons: computed(() => [
{ reason: defineMessage({ id: 's.bg', defaultMessage: 'Background task running' }) },
]),
fsAuth: ref(null),
fsOps: ref<Archon.Websocket.v0.FilesystemOperation[]>([]),
fsQueuedOps: ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([]),
refreshFsAuth: async () => {},
uploadState,
cancelUpload: ref(() => {
uploadState.value = { ...uploadState.value, isUploading: false }
}),
activeOperations: computed(() => fileOp.value),
dismissOperation: async (id) => {
fileOp.value = fileOp.value.filter((o) => o.id !== id)
},
}
provideModrinthServerContext(serverContext)
return {}
},
template: '<div style="max-width: 720px"><story /></div>',
}),
],
} satisfies Meta<typeof ServerPanelAdmonitions>
export default meta
type Story = StoryObj<typeof meta>
export const WithUploadFileOpAndBusy: Story = {}

View File

@@ -1,7 +1,7 @@
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { getCategoryIcon, GlobeIcon, SERVER_CATEGORY_ICON_MAP, UserIcon } from '@modrinth/assets' import { getCategoryIcon, GlobeIcon, SERVER_CATEGORY_ICON_MAP, UserIcon } from '@modrinth/assets'
import { sortedCategories } from '@modrinth/utils' import { sortedCategories } from '@modrinth/utils'
import { computed, type Ref, ref, shallowRef } from 'vue' import { computed, type ComputedRef, type Ref, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { defineMessage, LOCALES, useVIntl } from '../composables/i18n' import { defineMessage, LOCALES, useVIntl } from '../composables/i18n'
@@ -128,6 +128,7 @@ export function useServerSearch(opts: {
query: Ref<string> query: Ref<string>
maxResults: Ref<number> maxResults: Ref<number>
currentPage: Ref<number> currentPage: Ref<number>
providedFilters?: ComputedRef<FilterValue[]>
}) { }) {
const { tags, query, maxResults, currentPage } = opts const { tags, query, maxResults, currentPage } = opts
@@ -371,6 +372,31 @@ export function useServerSearch(opts: {
} }
} }
const providedProjectIds = (opts.providedFilters?.value ?? [])
.filter((filter) => filter.type === 'project_id')
.map((filter) => ({
projectId: filter.option.startsWith('project_id:')
? filter.option.slice('project_id:'.length)
: filter.option,
negative: !!filter.negative,
}))
.filter((filter) => filter.projectId.length > 0)
const excludedProjectIds = providedProjectIds
.filter((filter) => filter.negative)
.map((filter) => filter.projectId)
const includedProjectIds = providedProjectIds
.filter((filter) => !filter.negative)
.map((filter) => filter.projectId)
if (includedProjectIds.length > 0) {
const values = includedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
parts.push(`project_id IN [${values}]`)
}
if (excludedProjectIds.length > 0) {
const values = excludedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
parts.push(`project_id NOT IN [${values}]`)
}
return parts.join(' AND ') return parts.join(' AND ')
}) })

226
pnpm-lock.yaml generated
View File

@@ -71,6 +71,9 @@ importers:
apps/app-frontend: apps/app-frontend:
dependencies: dependencies:
'@intercom/messenger-js-sdk':
specifier: ^0.0.14
version: 0.0.14
'@modrinth/api-client': '@modrinth/api-client':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/api-client version: link:../../packages/api-client
@@ -167,7 +170,7 @@ importers:
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.4.1(eslint@9.39.2(jiti@1.21.7)) version: 1.4.1(eslint@9.39.2(jiti@2.6.1))
'@formatjs/cli': '@formatjs/cli':
specifier: ^6.2.12 specifier: ^6.2.12
version: 6.12.2(@vue/compiler-core@3.5.27)(vue@3.5.27(typescript@5.9.3)) version: 6.12.2(@vue/compiler-core@3.5.27)(vue@3.5.27(typescript@5.9.3))
@@ -176,22 +179,22 @@ importers:
version: link:../../packages/tooling-config version: link:../../packages/tooling-config
'@nuxt/eslint-config': '@nuxt/eslint-config':
specifier: ^0.5.6 specifier: ^0.5.6
version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@taijased/vue-render-tracker': '@taijased/vue-render-tracker':
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(vue@3.5.27(typescript@5.9.3)) version: 1.0.7(vue@3.5.27(typescript@5.9.3))
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) version: 6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))
autoprefixer: autoprefixer:
specifier: ^10.4.19 specifier: ^10.4.19
version: 10.4.24(postcss@8.5.6) version: 10.4.24(postcss@8.5.6)
eslint: eslint:
specifier: ^9.9.1 specifier: ^9.9.1
version: 9.39.2(jiti@1.21.7) version: 9.39.2(jiti@2.6.1)
eslint-plugin-turbo: eslint-plugin-turbo:
specifier: ^2.5.4 specifier: ^2.5.4
version: 2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2) version: 2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2)
postcss: postcss:
specifier: ^8.4.39 specifier: ^8.4.39
version: 8.5.6 version: 8.5.6
@@ -209,7 +212,7 @@ importers:
version: 5.9.3 version: 5.9.3
vite: vite:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) version: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue-component-type-helpers: vue-component-type-helpers:
specifier: ^3.1.8 specifier: ^3.1.8
version: 3.2.4 version: 3.2.4
@@ -694,6 +697,9 @@ importers:
markdown-it: markdown-it:
specifier: ^13.0.2 specifier: ^13.0.2
version: 13.0.2 version: 13.0.2
motion-v:
specifier: ^2.2.1
version: 2.2.1(@vueuse/core@11.3.0(vue@3.5.27(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.27(typescript@5.9.3))
postprocessing: postprocessing:
specifier: ^6.37.6 specifier: ^6.37.6
version: 6.38.2(three@0.172.0) version: 6.38.2(three@0.172.0)
@@ -751,7 +757,7 @@ importers:
version: 5.2.4(vite@5.4.21(@types/node@20.19.31)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3)) version: 5.2.4(vite@5.4.21(@types/node@20.19.31)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3))
eslint-plugin-storybook: eslint-plugin-storybook:
specifier: ^10.1.10 specifier: ^10.1.10
version: 10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) version: 10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
storybook: storybook:
specifier: ^10.1.10 specifier: ^10.1.10
version: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -6208,6 +6214,20 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0: fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -6426,6 +6446,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
highlight.js@11.11.1: highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -7397,6 +7420,18 @@ packages:
module-details-from-path@1.0.4: module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
motion-v@2.2.1:
resolution: {integrity: sha512-BYbABe1Ep/u33dHOrK+8SoVU2MuiQqT94JOYsgrge8QbrwkKf2lS6rHW2QyzP6t89wcyBvzZeLQQwfrx76dj9A==}
peerDependencies:
'@vueuse/core': '>=10.0.0'
vue: '>=3.0.0'
mrmime@2.0.1: mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -10967,11 +11002,11 @@ snapshots:
'@eslint-community/regexpp@4.12.2': {} '@eslint-community/regexpp@4.12.2': {}
'@eslint/compat@1.4.1(eslint@9.39.2(jiti@1.21.7))': '@eslint/compat@1.4.1(eslint@9.39.2(jiti@2.6.1))':
dependencies: dependencies:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
optionalDependencies: optionalDependencies:
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
'@eslint/config-array@0.21.1': '@eslint/config-array@0.21.1':
dependencies: dependencies:
@@ -11592,36 +11627,36 @@ snapshots:
- utf-8-validate - utf-8-validate
- vue - vue
'@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': '@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint/js': 9.39.2 '@eslint/js': 9.39.2
'@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@1.21.7)) eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@2.6.1))
eslint-flat-config-utils: 0.4.0 eslint-flat-config-utils: 0.4.0
eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@2.6.1))
globals: 15.15.0 globals: 15.15.0
local-pkg: 0.5.1 local-pkg: 0.5.1
pathe: 1.1.2 pathe: 1.1.2
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7)) vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies: transitivePeerDependencies:
- '@typescript-eslint/utils' - '@typescript-eslint/utils'
- eslint-import-resolver-node - eslint-import-resolver-node
- supports-color - supports-color
- typescript - typescript
'@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': '@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.54.0 '@typescript-eslint/types': 8.54.0
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
@@ -13091,18 +13126,6 @@ snapshots:
'@stripe/stripe-js@7.9.0': {} '@stripe/stripe-js@7.9.0': {}
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
eslint-visitor-keys: 4.2.1
espree: 10.4.0
estraverse: 5.3.0
picomatch: 4.0.3
transitivePeerDependencies:
- supports-color
- typescript
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -13114,7 +13137,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
optional: true
'@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)': '@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)':
dependencies: dependencies:
@@ -13516,22 +13538,6 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.31 '@types/node': 20.19.31
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
eslint: 9.39.2(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -13548,18 +13554,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/scope-manager': 8.54.0
@@ -13590,18 +13584,6 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.54.0 '@typescript-eslint/types': 8.54.0
@@ -13772,10 +13754,10 @@ snapshots:
vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue: 3.5.27(typescript@5.9.3) vue: 3.5.27(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': '@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2 '@rolldown/pluginutils': 1.0.0-rc.2
vite: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vite: 8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue: 3.5.27(typescript@5.9.3) vue: 3.5.27(typescript@5.9.3)
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
@@ -15301,10 +15283,10 @@ snapshots:
escape-string-regexp@5.0.0: {} escape-string-regexp@5.0.0: {}
eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@1.21.7)): eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@eslint/compat': 1.4.1(eslint@9.39.2(jiti@1.21.7)) '@eslint/compat': 1.4.1(eslint@9.39.2(jiti@2.6.1))
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
find-up-simple: 1.0.1 find-up-simple: 1.0.1
eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
@@ -15322,12 +15304,12 @@ snapshots:
optionalDependencies: optionalDependencies:
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@typescript-eslint/types': 8.54.0 '@typescript-eslint/types': 8.54.0
comment-parser: 1.4.5 comment-parser: 1.4.5
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 10.1.2 minimatch: 10.1.2
@@ -15335,18 +15317,18 @@ snapshots:
stable-hash-x: 0.2.0 stable-hash-x: 0.2.0
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@1.21.7)): eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@es-joy/jsdoccomment': 0.50.2 '@es-joy/jsdoccomment': 0.50.2
are-docs-informative: 0.0.2 are-docs-informative: 0.0.2
comment-parser: 1.4.1 comment-parser: 1.4.1
debug: 4.4.3 debug: 4.4.3
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
espree: 10.4.0 espree: 10.4.0
esquery: 1.7.0 esquery: 1.7.0
parse-imports-exports: 0.2.4 parse-imports-exports: 0.2.4
@@ -15364,12 +15346,12 @@ snapshots:
optionalDependencies: optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@1.21.7)): eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
comment-parser: 1.4.5 comment-parser: 1.4.5
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
jsdoc-type-pratt-parser: 4.8.0 jsdoc-type-pratt-parser: 4.8.0
refa: 0.12.1 refa: 0.12.1
regexp-ast-analysis: 0.7.1 regexp-ast-analysis: 0.7.1
@@ -15379,29 +15361,29 @@ snapshots:
dependencies: dependencies:
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@1.21.7)
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2): eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2):
dependencies: dependencies:
dotenv: 16.0.3 dotenv: 16.0.3
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
turbo: 2.8.2 turbo: 2.8.2
eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@1.21.7)): eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
ci-info: 4.4.0 ci-info: 4.4.0
clean-regexp: 1.0.0 clean-regexp: 1.0.0
core-js-compat: 3.48.0 core-js-compat: 3.48.0
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
esquery: 1.7.0 esquery: 1.7.0
globals: 15.15.0 globals: 15.15.0
indent-string: 4.0.0 indent-string: 4.0.0
@@ -15428,16 +15410,16 @@ snapshots:
'@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@1.21.7)): eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
globals: 13.24.0 globals: 13.24.0
natural-compare: 1.4.0 natural-compare: 1.4.0
nth-check: 2.1.1 nth-check: 2.1.1
postcss-selector-parser: 6.1.2 postcss-selector-parser: 6.1.2
semver: 7.7.3 semver: 7.7.3
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7)) vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1))
xml-name-validator: 4.0.0 xml-name-validator: 4.0.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -15755,6 +15737,15 @@ snapshots:
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
fresh@2.0.0: {} fresh@2.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
@@ -16108,6 +16099,8 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
hey-listen@1.0.8: {}
highlight.js@11.11.1: {} highlight.js@11.11.1: {}
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e: {} highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e: {}
@@ -17302,6 +17295,25 @@ snapshots:
module-details-from-path@1.0.4: {} module-details-from-path@1.0.4: {}
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
motion-utils@12.36.0: {}
motion-v@2.2.1(@vueuse/core@11.3.0(vue@3.5.27(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.27(typescript@5.9.3)):
dependencies:
'@vueuse/core': 11.3.0(vue@3.5.27(typescript@5.9.3))
framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
hey-listen: 1.0.8
motion-dom: 12.38.0
motion-utils: 12.36.0
vue: 3.5.27(typescript@5.9.3)
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- react
- react-dom
mrmime@2.0.1: {} mrmime@2.0.1: {}
ms@2.1.3: {} ms@2.1.3: {}
@@ -19817,7 +19829,7 @@ snapshots:
terser: 5.46.0 terser: 5.46.0
yaml: 2.8.2 yaml: 2.8.2
vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): vite@8.0.3(@types/node@20.19.31)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@@ -19828,7 +19840,7 @@ snapshots:
'@types/node': 20.19.31 '@types/node': 20.19.31
esbuild: 0.27.3 esbuild: 0.27.3
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 1.21.7 jiti: 2.6.1
sass: 1.97.3 sass: 1.97.3
terser: 5.46.0 terser: 5.46.0
yaml: 2.8.2 yaml: 2.8.2
@@ -19993,10 +20005,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@1.21.7)): vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@2.6.1)
eslint-scope: 7.2.2 eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
espree: 9.6.1 espree: 9.6.1