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:
@@ -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:*",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
1
apps/frontend/AGENTS.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
CLAUDE.md
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
1
apps/labrinth/AGENTS.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
CLAUDE.md
|
||||||
1
packages/api-client/AGENTS.md
Symbolic link
1
packages/api-client/AGENTS.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
CLAUDE.md
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
93
packages/api-client/src/modules/archon/backups-queue/v1.ts
Normal file
93
packages/api-client/src/modules/archon/backups-queue/v1.ts
Normal 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' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
11
packages/assets/styles/reset.scss
Normal file
11
packages/assets/styles/reset.scss
Normal 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
1
packages/ui/AGENTS.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
CLAUDE.md
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
617
packages/ui/src/components/base/StackedAdmonitions.vue
Normal file
617
packages/ui/src/components/base/StackedAdmonitions.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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...',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
5
packages/ui/src/components/servers/admonitions/index.ts
Normal file
5
packages/ui/src/components/servers/admonitions/index.ts
Normal 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'
|
||||||
@@ -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 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
133
packages/ui/src/composables/server-backups-queue.ts
Normal file
133
packages/ui/src/composables/server-backups-queue.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
507
packages/ui/src/stories/base/StackedAdmonitions.stories.ts
Normal file
507
packages/ui/src/stories/base/StackedAdmonitions.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
99
packages/ui/src/stories/servers/BackupItem.stories.ts
Normal file
99
packages/ui/src/stories/servers/BackupItem.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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' }" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -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 = {}
|
||||||
@@ -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
226
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user