feat: backups page cleanup before worlds (#5844)

* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

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

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

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

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

* feat: move action bar behind modal

* fix: lint + i18n

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

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

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

View File

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

View File

@@ -1,4 +1,5 @@
<script setup>
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
import {
AuthFeature,
NodeAuthFeature,
@@ -238,6 +239,7 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
await unlistenUpdateDownload?.()
})
@@ -652,6 +654,102 @@ const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value
const showAd = computed(
() => 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, () => {
if (!showAd.value) {

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
@@ -47,12 +46,10 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
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 }> {
const credentials = await getCreds().catch(() => null)
if (!credentials?.user_id) {

View File

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

View File

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

View File

@@ -12,7 +12,12 @@
"copyright": "",
"targets": "all",
"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": {
"nsis": {
"installMode": "currentUser",
@@ -35,7 +40,9 @@
},
"fileAssociations": [
{
"ext": ["mrpack"],
"ext": [
"mrpack"
],
"mimeType": "application/x-modrinth-modpack+zip"
}
]
@@ -47,7 +54,9 @@
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["modrinth"]
"schemes": [
"modrinth"
]
},
"mobile": []
}
@@ -84,15 +93,22 @@
],
"enable": true
},
"capabilities": ["ads", "core", "plugins"],
"capabilities": [
"ads",
"core",
"plugins"
],
"csp": {
"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:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"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/",
"https://js.intercomcdn.com"
],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"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'",
"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'",
"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 https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'",
"media-src": "https://*.githubusercontent.com"
}
}

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

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

View File

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

View File

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

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

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