add loading states for creators + server details (#7033)

* add loading states for creators + server details

* prepr
This commit is contained in:
Prospector
2026-08-07 13:20:31 -07:00
committed by GitHub
parent 9181c155e0
commit b0394de164
4 changed files with 92 additions and 36 deletions
+16 -3
View File
@@ -432,7 +432,8 @@
<div class="normal-page__sidebar"> <div class="normal-page__sidebar">
<ProjectSidebarServerInfo <ProjectSidebarServerInfo
v-if="isServerProject && serverDataLoaded" v-if="isServerProject"
:loading="!serverDataLoaded"
:project-v3="projectV3" :project-v3="projectV3"
:tags="tags" :tags="tags"
:required-content="serverRequiredContent" :required-content="serverRequiredContent"
@@ -462,6 +463,7 @@
<ProjectSidebarCreators <ProjectSidebarCreators
:organization="organization" :organization="organization"
:members="members" :members="members"
:loading="creatorsLoading"
:org-link="(slug) => `/organization/${slug}`" :org-link="(slug) => `/organization/${slug}`"
:user-link="(username) => `/user/${username}`" :user-link="(username) => `/user/${username}`"
class="card flex-card" class="card flex-card"
@@ -1023,7 +1025,11 @@ watch(serverModpackVersionId, (versionId) => {
}) })
// Members // Members
const { data: allMembersRaw, error: _membersError } = useQuery({ const {
data: allMembersRaw,
error: _membersError,
isPending: membersPending,
} = useQuery({
queryKey: computed(() => ['project', projectId.value, 'members']), queryKey: computed(() => ['project', projectId.value, 'members']),
queryFn: () => client.labrinth.projects_v3.getMembers(projectId.value), queryFn: () => client.labrinth.projects_v3.getMembers(projectId.value),
staleTime: STALE_TIME, staleTime: STALE_TIME,
@@ -1074,7 +1080,7 @@ const {
// Organization // Organization
// Only fetch organization if project belongs to one // Only fetch organization if project belongs to one
const { data: organizationRaw } = useQuery({ const { data: organizationRaw, isPending: organizationPending } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'organization']), queryKey: computed(() => ['project', projectId.value, 'organization']),
queryFn: () => client.labrinth.projects_v3.getOrganization(projectId.value), queryFn: () => client.labrinth.projects_v3.getOrganization(projectId.value),
staleTime: STALE_TIME, staleTime: STALE_TIME,
@@ -1085,6 +1091,13 @@ const { data: organizationRaw } = useQuery({
// Return null when the project no longer belongs to an organization. // Return null when the project no longer belongs to an organization.
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null)) const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
const creatorsLoading = computed(
() =>
!projectRaw.value ||
membersPending.value ||
(!!projectRaw.value.organization && organizationPending.value),
)
const { data: thread } = useQuery({ const { data: thread } = useQuery({
queryKey: computed(() => ['thread', projectRaw.value?.thread_id]), queryKey: computed(() => ['thread', projectRaw.value?.thread_id]),
queryFn: () => client.labrinth.threads_v3.getThread(projectRaw.value.thread_id), queryFn: () => client.labrinth.threads_v3.getThread(projectRaw.value.thread_id),
@@ -2,45 +2,59 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<h2 class="text-lg font-semibold m-0">{{ formatMessage(messages.title) }}</h2> <h2 class="text-lg font-semibold m-0">{{ formatMessage(messages.title) }}</h2>
<div class="flex flex-col gap-3 font-semibold"> <div class="flex flex-col gap-3 font-semibold">
<template v-if="organization"> <template v-if="loading">
<div v-for="i in 2" :key="`creator-skeleton-${i}`" class="flex gap-2 items-center">
<div class="size-[32px] rounded-full bg-surface-4 animate-pulse"></div>
<div class="flex flex-col gap-1.5">
<div class="w-24 h-4 rounded-full bg-surface-4 animate-pulse"></div>
<div class="w-16 h-3 rounded-full bg-surface-4 animate-pulse"></div>
</div>
</div>
</template>
<template v-else-if="isEmpty">
<span class="text-red"> Error: Project has no members. This shouldn't happen. </span>
</template>
<template v-else>
<template v-if="organization">
<AutoLink
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="orgLink(organization.slug)"
:target="linkTarget ?? null"
>
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="flex flex-col flex-nowrap justify-center">
<span class="group-hover:underline font-medium">
{{ organization.name }}
</span>
<span class="text-sm font-normal text-secondary flex items-center gap-1"
><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span
>
</div>
</AutoLink>
<hr v-if="sortedMembers.length > 0" class="w-full border-button-border my-0.5" />
</template>
<AutoLink <AutoLink
v-for="member in sortedMembers"
:key="`member-${member.id}`"
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group" class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="orgLink(organization.slug)" :to="userLink(member.user.username)"
:target="linkTarget ?? null" :target="resolveLinkTarget(userLinkTarget)"
> >
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" /> <Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col flex-nowrap justify-center"> <div class="flex flex-col">
<span class="group-hover:underline font-medium"> <span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
{{ organization.name }} <span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(messages.owner)"
class="text-brand-orange"
/>
<ExternalIcon v-if="resolveLinkTarget(userLinkTarget) === '_blank'" />
</span> </span>
<span class="text-sm font-normal text-secondary flex items-center gap-1" <span class="text-sm font-normal text-secondary">{{ member.role }}</span>
><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span
>
</div> </div>
</AutoLink> </AutoLink>
<hr v-if="sortedMembers.length > 0" class="w-full border-button-border my-0.5" />
</template> </template>
<AutoLink
v-for="member in sortedMembers"
:key="`member-${member.id}`"
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="userLink(member.user.username)"
:target="resolveLinkTarget(userLinkTarget)"
>
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(messages.owner)"
class="text-brand-orange"
/>
<ExternalIcon v-if="resolveLinkTarget(userLinkTarget) === '_blank'" />
</span>
<span class="text-sm font-normal text-secondary">{{ member.role }}</span>
</div>
</AutoLink>
</div> </div>
</div> </div>
</template> </template>
@@ -80,6 +94,7 @@ const props = defineProps<{
userLink: (username: string) => string userLink: (username: string) => string
linkTarget?: string linkTarget?: string
userLinkTarget?: string | null userLinkTarget?: string | null
loading?: boolean
}>() }>()
function resolveLinkTarget(target: string | null | undefined): string | null { function resolveLinkTarget(target: string | null | undefined): string | null {
@@ -113,6 +128,8 @@ const sortedMembers = computed(() => {
return owner ? [owner, ...rest] : rest return owner ? [owner, ...rest] : rest
}) })
const isEmpty = computed(() => !props.organization && sortedMembers.value.length === 0)
const messages = defineMessages({ const messages = defineMessages({
title: { title: {
id: 'project.about.creators.title', id: 'project.about.creators.title',
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="hasContent" class="flex flex-col gap-3"> <div v-if="loading || hasContent" class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2> <h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div <div
@@ -15,6 +15,10 @@
<CopyIcon class="shrink-0" /> <CopyIcon class="shrink-0" />
</div> </div>
</div> </div>
<div
v-else-if="loading && !projectV3"
class="h-12 rounded-2xl bg-surface-4 animate-pulse"
></div>
<section v-if="requiredContent" class="flex flex-col gap-2"> <section v-if="requiredContent" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.requiredContent) }}</h3> <h3 class="text-primary text-base m-0">{{ formatMessage(messages.requiredContent) }}</h3>
@@ -26,8 +30,13 @@
:onclick-version="requiredContent.onclickVersion" :onclick-version="requiredContent.onclickVersion"
:onclick-download="requiredContent.onclickDownload" :onclick-download="requiredContent.onclickDownload"
:show-custom-modpack-tooltip="requiredContent.showCustomModpackTooltip" :show-custom-modpack-tooltip="requiredContent.showCustomModpackTooltip"
:loading-version="loading"
/> />
</section> </section>
<section v-else-if="loading" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.requiredContent) }}</h3>
<div class="h-[52px] rounded-2xl bg-surface-4 animate-pulse"></div>
</section>
<section v-if="recommendedVersions.length" class="flex flex-col gap-2"> <section v-if="recommendedVersions.length" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3> <h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
@@ -57,6 +66,17 @@
</TagItem> </TagItem>
</div> </div>
</section> </section>
<section v-else-if="loading" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3>
<div class="flex flex-wrap gap-1.5">
<div
v-for="width in ['w-16', 'w-20']"
:key="`version-skeleton-${width}`"
class="h-[26px] rounded-full bg-surface-4 animate-pulse"
:class="width"
></div>
</div>
</section>
<section v-if="props.ping !== undefined || region" class="flex flex-col gap-2"> <section v-if="props.ping !== undefined || region" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.region) }}</h3> <h3 class="text-primary text-base m-0">{{ formatMessage(messages.region) }}</h3>
<div class="flex flex-wrap gap-1.5 items-center"> <div class="flex flex-wrap gap-1.5 items-center">
@@ -115,6 +135,7 @@ interface Props {
loaders?: string[] loaders?: string[]
ping?: number ping?: number
statusOnline?: boolean statusOnline?: boolean
loading?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -27,6 +27,10 @@
> >
{{ versionNumber }} {{ versionNumber }}
</div> </div>
<div
v-else-if="loadingVersion"
class="w-16 h-4 my-0.5 rounded-full bg-surface-4 animate-pulse"
></div>
</div> </div>
</div> </div>
<IconButton <IconButton
@@ -58,6 +62,7 @@ defineProps<{
onclickVersion?: () => void onclickVersion?: () => void
onclickDownload?: () => void onclickDownload?: () => void
showCustomModpackTooltip?: boolean showCustomModpackTooltip?: boolean
loadingVersion?: boolean
}>() }>()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()