qa: content (#7184)

* qa: content card improvements for server panel

* fix: server install icon bug

* qa: 1

* fix: lint

* qa: 2
This commit is contained in:
Calum H.
2026-08-18 00:39:51 +00:00
committed by GitHub
parent 2f01c29b7d
commit bac2a55bb7
11 changed files with 101 additions and 95 deletions
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets' import { CheckIcon, PlayIcon, PlusIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui' import type { CardAction } from '@modrinth/ui'
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui' import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
@@ -75,6 +75,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const debugLog = useDebugLogger('BrowseServer') const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({}) const serverPings = shallowRef<Record<string, number | undefined>>({})
const runningServerProjects = ref<Record<string, string>>({}) const runningServerProjects = ref<Record<string, string>>({})
const preparingServerProjects = ref<string[]>([])
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([]) const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null) const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingsActive = true let serverPingsActive = true
@@ -109,9 +110,16 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
} }
async function handlePlayServerProject(projectId: string) { async function handlePlayServerProject(projectId: string) {
if (preparingServerProjects.value.includes(projectId)) return
debugLog('handlePlayServerProject', projectId) debugLog('handlePlayServerProject', projectId)
await options.playServerProject(projectId) preparingServerProjects.value.push(projectId)
checkServerRunningStates(lastServerHits.value) try {
await options.playServerProject(projectId)
checkServerRunningStates(lastServerHits.value)
} finally {
preparingServerProjects.value = preparingServerProjects.value.filter((id) => id !== projectId)
}
} }
async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) { async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
@@ -247,13 +255,16 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
}) })
} else { } else {
const isInstalling = options.installingServerProjects.value.includes(serverResult.project_id) const isInstalling = options.installingServerProjects.value.includes(serverResult.project_id)
const isPreparing = preparingServerProjects.value.includes(serverResult.project_id)
const isBusy = isInstalling || isPreparing
actions.push({ actions.push({
key: 'play', key: 'play',
label: formatMessage( label: formatMessage(
isInstalling ? commonMessages.installingLabel : commonMessages.playButton, isInstalling ? commonMessages.installingLabel : commonMessages.playButton,
), ),
icon: PlayIcon, icon: isBusy ? SpinnerIcon : PlayIcon,
disabled: isInstalling, iconClass: isBusy ? 'animate-spin' : undefined,
disabled: isBusy,
color: 'brand', color: 'brand',
type: 'outlined', type: 'outlined',
onClick: () => handlePlayServerProject(serverResult.project_id), onClick: () => handlePlayServerProject(serverResult.project_id),
@@ -33,7 +33,7 @@
:ping="ping" :ping="ping"
:minecraft-server="minecraftServer" :minecraft-server="minecraftServer"
:show-instance-play-time="showInstancePlayTime" :show-instance-play-time="showInstancePlayTime"
:playtime-label="playtimeLabel" :playtime-label="playtimeLabel ?? formatMessage(messages.neverPlayed)"
/> />
<PageHeaderMetadata v-else> <PageHeaderMetadata v-else>
<PageHeaderMetadataItem <PageHeaderMetadataItem
@@ -39,7 +39,7 @@ export interface ServerInstallContext {
project: Labrinth.Projects.v3.Project, project: Labrinth.Projects.v3.Project,
modpackVersionId: string | null, modpackVersionId: string | null,
callback?: () => void, callback?: () => void,
) => void ) => Promise<void>
>, >,
) => void ) => void
setUpdateToPlayModal: ( setUpdateToPlayModal: (
@@ -84,7 +84,7 @@ export function createServerInstall(opts: {
project: Labrinth.Projects.v3.Project, project: Labrinth.Projects.v3.Project,
modpackVersionId: string | null, modpackVersionId: string | null,
callback?: () => void, callback?: () => void,
) => void ) => Promise<void>
> | null = null > | null = null
let updateToPlayModalRef: ModalRef< let updateToPlayModalRef: ModalRef<
(instance: GameInstance, activeVersionId: string | null, callback?: () => void) => void (instance: GameInstance, activeVersionId: string | null, callback?: () => void) => void
@@ -246,10 +246,10 @@ export function createServerInstall(opts: {
project_id: contentProjectId, project_id: contentProjectId,
version_id: contentVersionId, version_id: contentVersionId,
title: project.title, title: project.title,
icon_url: project.icon_url,
}, },
{ {
name: project.title, name: project.title,
iconPath: project.icon_url ?? null,
link: { link: {
type: 'server_project_modpack', type: 'server_project_modpack',
server_project_id: serverProjectId, server_project_id: serverProjectId,
@@ -314,7 +314,7 @@ export function createServerInstall(opts: {
return return
} }
if (isModpack && !instance) { if (isModpack && !instance) {
installToPlayModalRef?.show(projectV3, modpackVersionId, async () => { await installToPlayModalRef?.show(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id) const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return if (!newInstance) return
showModpackInstallSuccess(newInstance, serverAddress) showModpackInstallSuccess(newInstance, serverAddress)
+42 -41
View File
@@ -309,7 +309,6 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
reporter: InstallProgressReporter, reporter: InstallProgressReporter,
) -> crate::Result<CreatePack> { ) -> crate::Result<CreatePack> {
let state = State::get().await?; let state = State::get().await?;
let has_icon_url = icon_url.is_some();
let version = CachedEntry::get_version( let version = CachedEntry::get_version(
&version_id, &version_id,
@@ -443,48 +442,28 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
.update(InstallPhaseId::ResolvingPack, None, details.clone()) .update(InstallPhaseId::ResolvingPack, None, details.clone())
.await?; .await?;
let project = CachedEntry::get_project( // When no icon URL is supplied, preserve the instance's existing icon.
&version.project_id, let icon = if let Some(icon_url) = icon_url {
None, reporter
&state.pool, .set_context(
&state.api_semaphore, InstallErrorContext::new("download modpack icon")
) .urls(vec![icon_url.clone()])
.await? .project_id(project_id.clone())
.ok_or_else(|| { .version_id(version_id.clone())
crate::ErrorKind::InputError( .build(),
"Invalid project ID specified!".to_string(),
)
})?;
// Only fetch the pack icon when icon_url is provided (new profile).
// When installing to an existing profile (e.g. server projects),
// icon_url is None and we preserve the profile's existing icon.
let icon = if has_icon_url {
if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
reporter
.set_context(
InstallErrorContext::new("download modpack icon")
.urls(vec![icon_url.clone()])
.project_id(project_id.clone())
.version_id(version_id.clone())
.build(),
)
.await?;
let icon_bytes = fetch(
&icon_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
) )
.await?; .await?;
let icon_bytes = fetch(
&icon_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?) Some(crate::api::instance::cache_icon(icon_bytes, &state).await?)
} else {
None
}
} else { } else {
None None
}; };
@@ -587,7 +566,7 @@ pub async fn set_instance_information(
} else { } else {
None None
}; };
let link = match (&description.project_id, &description.version_id) { let pack_link = match (&description.project_id, &description.version_id) {
(Some(project_id), Some(version_id)) => { (Some(project_id), Some(version_id)) => {
Some(InstanceLink::ModrinthModpack { Some(InstanceLink::ModrinthModpack {
project_id: project_id.clone(), project_id: project_id.clone(),
@@ -605,13 +584,35 @@ pub async fn set_instance_information(
} }
_ => None, _ => None,
}; };
let existing_link = crate::api::instance::get(&instance_id)
.await?
.map(|metadata| metadata.link);
let link = match existing_link {
Some(
link @ (InstanceLink::ServerProject { .. }
| InstanceLink::ServerProjectModpack { .. }
| InstanceLink::ModrinthHosting { .. }
| InstanceLink::SharedInstance { .. }),
) => Some(link),
_ => pack_link,
};
let source_kind = match &link { let source_kind = match &link {
Some(InstanceLink::ModrinthModpack { .. }) => { Some(InstanceLink::ModrinthModpack { .. }) => {
Some(ContentSourceKind::ModrinthModpack) Some(ContentSourceKind::ModrinthModpack)
} }
Some(
InstanceLink::ServerProject { .. }
| InstanceLink::ServerProjectModpack { .. },
) => Some(ContentSourceKind::ServerProject),
Some(InstanceLink::ModrinthHosting { .. }) => {
Some(ContentSourceKind::ModrinthHosting)
}
Some(InstanceLink::ImportedModpack { .. }) => { Some(InstanceLink::ImportedModpack { .. }) => {
Some(ContentSourceKind::ImportedModpack) Some(ContentSourceKind::ImportedModpack)
} }
Some(InstanceLink::SharedInstance { .. }) => {
Some(ContentSourceKind::SharedInstance)
}
_ => None, _ => None,
}; };
crate::api::instance::edit( crate::api::instance::edit(
@@ -130,7 +130,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'"> <template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
<div <div
ref="stickyInstallHeaderRef" ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 px-3 py-4 border-surface-5" class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 px-6 py-4 border-surface-5"
:class="[isInstallHeaderStuck ? 'border-t' : '']" :class="[isInstallHeaderStuck ? 'border-t' : '']"
> >
<BrowseInstallHeader /> <BrowseInstallHeader />
@@ -2,6 +2,8 @@
import { EyeIcon, WrenchIcon } from '@modrinth/assets' import { EyeIcon, WrenchIcon } from '@modrinth/assets'
import { computed } from 'vue' import { computed } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import { Button, IconButton } from '#ui/components/base/buttons' import { Button, IconButton } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -67,9 +69,20 @@ const title = computed(() =>
<div <div
class="flex min-h-[94px] flex-col items-stretch justify-between gap-4 bg-surface-3 px-[18px] py-4 @[700px]:flex-row @[700px]:items-center" class="flex min-h-[94px] flex-col items-stretch justify-between gap-4 bg-surface-3 px-[18px] py-4 @[700px]:flex-row @[700px]:items-center"
> >
<div class="flex min-w-0 flex-1 flex-col gap-1.5 pl-0.5"> <div class="flex min-w-0 flex-1 items-center gap-4 pl-0.5">
<h2 class="m-0 text-2xl font-semibold leading-8 text-contrast">{{ title }}</h2> <AutoLink v-if="data.kind === 'modpack'" :to="data.manager.link" class="shrink-0">
<ManagedContentCardSummary :summary="data.summary" :installing="data.installing" /> <Avatar
:src="data.manager.iconUrl"
:alt="data.manager.name"
size="4rem"
:tint-by="data.manager.name"
no-shadow
/>
</AutoLink>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<h2 class="m-0 text-2xl font-semibold leading-8 text-contrast">{{ title }}</h2>
<ManagedContentCardSummary :summary="data.summary" :installing="data.installing" />
</div>
</div> </div>
<div <div
@@ -44,9 +44,9 @@ const formatDateTime = useFormatDateTime({
}) })
const messages = defineMessages({ const messages = defineMessages({
managedBy: { providedBy: {
id: 'content.managed-card.managed-by', id: 'content.managed-card.provided-by',
defaultMessage: 'Managed by', defaultMessage: 'Provided by',
}, },
server: { server: {
id: 'content.managed-card.server-suffix', id: 'content.managed-card.server-suffix',
@@ -110,7 +110,7 @@ const showAction = computed(
<LockIcon aria-hidden="true" class="size-5 shrink-0" /> <LockIcon aria-hidden="true" class="size-5 shrink-0" />
<div class="flex min-w-0 flex-wrap items-center gap-[5px] @[700px]:flex-nowrap"> <div class="flex min-w-0 flex-wrap items-center gap-[5px] @[700px]:flex-nowrap">
<span class="whitespace-nowrap"> <span class="whitespace-nowrap">
{{ formatMessage(messages.managedBy) }} {{ formatMessage(messages.providedBy) }}
</span> </span>
<AutoLink <AutoLink
:to="data.manager.link" :to="data.manager.link"
@@ -23,8 +23,8 @@ const messages = defineMessages({
defaultMessage: 'Loading managed content summary', defaultMessage: 'Loading managed content summary',
}, },
empty: { empty: {
id: 'content.managed-card.summary.empty', id: 'content.managed-card.summary.no-provided-content',
defaultMessage: 'No managed content', defaultMessage: 'No provided content',
}, },
installing: { installing: {
id: 'content.managed-card.summary.installing', id: 'content.managed-card.summary.installing',
@@ -115,10 +115,6 @@ const messages = defineMessages({
id: 'content.metadata-filter.state', id: 'content.metadata-filter.state',
defaultMessage: 'State', defaultMessage: 'State',
}, },
updates: {
id: 'content.metadata-filter.updates',
defaultMessage: 'Updates',
},
warnings: { warnings: {
id: 'content.metadata-filter.warnings', id: 'content.metadata-filter.warnings',
defaultMessage: 'Warnings', defaultMessage: 'Warnings',
@@ -135,10 +131,6 @@ const messages = defineMessages({
id: 'content.metadata-filter.update.available', id: 'content.metadata-filter.update.available',
defaultMessage: 'Update available', defaultMessage: 'Update available',
}, },
upToDate: {
id: 'content.metadata-filter.update.up-to-date',
defaultMessage: 'Up to date',
},
clientRetained: { clientRetained: {
id: 'content.metadata-filter.warning.client-retained', id: 'content.metadata-filter.warning.client-retained',
defaultMessage: 'Client file retained', defaultMessage: 'Client file retained',
@@ -237,15 +229,6 @@ export function useContentMetadataFilters(
: option('disabled', formatMessage(messages.disabled)), : option('disabled', formatMessage(messages.disabled)),
], ],
}, },
{
key: 'updates',
label: formatMessage(messages.updates),
values: (item) => [
item.has_update
? option('available', formatMessage(messages.updateAvailable))
: option('current', formatMessage(messages.upToDate)),
],
},
{ {
key: 'warnings', key: 'warnings',
label: formatMessage(messages.warnings), label: formatMessage(messages.warnings),
@@ -263,6 +246,13 @@ export function useContentMetadataFilters(
} }
}, },
}, },
{
key: 'updates',
label: formatMessage(messages.updateAvailable),
direct: true,
values: (item) =>
item.has_update ? [option('available', formatMessage(messages.updateAvailable))] : [],
},
{ {
key: 'open_source', key: 'open_source',
label: formatMessage(messages.openSource), label: formatMessage(messages.openSource),
@@ -1024,11 +1024,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
class="flex flex-wrap items-center gap-1.5 [&>div:last-of-type]:!h-[34px] [&>div:last-of-type]:!gap-1.5 [&_[data-button]]:!h-[34px]" class="flex flex-wrap items-center gap-1.5 [&>div:last-of-type]:!h-[34px] [&>div:last-of-type]:!gap-1.5 [&_[data-button]]:!h-[34px]"
> >
<div <div
class="h-6 w-px shrink-0 bg-surface-5" class="mr-0.5 h-6 w-px shrink-0 bg-surface-5"
:class="{ :class="{ invisible: metadataFiltersWrapped }"
hidden: metadataFiltersWrapped,
'mr-0.5': !metadataFiltersWrapped,
}"
/> />
<DropdownFilterBar <DropdownFilterBar
v-model="selectedMetadataFilters" v-model="selectedMetadataFilters"
+6 -12
View File
@@ -587,12 +587,12 @@
"content.inline-backup.world-label": { "content.inline-backup.world-label": {
"defaultMessage": "world" "defaultMessage": "world"
}, },
"content.managed-card.managed-by": {
"defaultMessage": "Managed by"
},
"content.managed-card.modpack-content": { "content.managed-card.modpack-content": {
"defaultMessage": "Modpack content" "defaultMessage": "Modpack content"
}, },
"content.managed-card.provided-by": {
"defaultMessage": "Provided by"
},
"content.managed-card.server-suffix": { "content.managed-card.server-suffix": {
"defaultMessage": "server" "defaultMessage": "server"
}, },
@@ -602,15 +602,15 @@
"content.managed-card.shared-content": { "content.managed-card.shared-content": {
"defaultMessage": "Shared content" "defaultMessage": "Shared content"
}, },
"content.managed-card.summary.empty": {
"defaultMessage": "No managed content"
},
"content.managed-card.summary.installing": { "content.managed-card.summary.installing": {
"defaultMessage": "Installing content..." "defaultMessage": "Installing content..."
}, },
"content.managed-card.summary.loading": { "content.managed-card.summary.loading": {
"defaultMessage": "Loading managed content summary" "defaultMessage": "Loading managed content summary"
}, },
"content.managed-card.summary.no-provided-content": {
"defaultMessage": "No provided content"
},
"content.managed-card.switch-version": { "content.managed-card.switch-version": {
"defaultMessage": "Switch version" "defaultMessage": "Switch version"
}, },
@@ -656,12 +656,6 @@
"content.metadata-filter.update.available": { "content.metadata-filter.update.available": {
"defaultMessage": "Update available" "defaultMessage": "Update available"
}, },
"content.metadata-filter.update.up-to-date": {
"defaultMessage": "Up to date"
},
"content.metadata-filter.updates": {
"defaultMessage": "Updates"
},
"content.metadata-filter.warning.client-depends": { "content.metadata-filter.warning.client-depends": {
"defaultMessage": "Client depends on file" "defaultMessage": "Client depends on file"
}, },