feat: move users page to xplat page system (#6889)

* feat: move users page to xplat

* feat: stop doing tauri plugin open for user links

* feat: qa

* fix: qa

* fix: qa

* fix: comment

* fix: prepr

* prepr

* prepr

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-27 20:02:33 +00:00
committed by GitHub
co-authored by Prospector
parent ebc7cac8b0
commit 7c4190fb9c
64 changed files with 1569 additions and 4028 deletions
+100 -1
View File
@@ -1,7 +1,8 @@
use crate::State;
use crate::util::fetch::fetch_json;
use crate::util::fetch::{fetch_advanced, fetch_json};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchUser {
@@ -30,3 +31,101 @@ pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
)
.await
}
#[tracing::instrument]
pub async fn get_user_profile(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!("{}user/{}", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
Some("/v3/user/:id"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_projects(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!("{}user/{}/projects", env!("MODRINTH_API_URL"), user_id),
None,
None,
Some("/v2/user/:id/projects"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_organizations(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!(
"{}user/{}/organizations",
env!("MODRINTH_API_URL_V3"),
user_id
),
None,
None,
Some("/v3/user/:id/organizations"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_collections(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!(
"{}user/{}/collections",
env!("MODRINTH_API_URL_V3"),
user_id
),
None,
None,
Some("/v3/user/:id/collections"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument(skip(patch))]
pub async fn patch_user(user_id: &str, patch: Value) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::PATCH,
&format!("{}user/{}", env!("MODRINTH_API_URL"), user_id),
None,
Some(patch),
None,
None,
None,
Some("/v2/user/:id"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
@@ -25,7 +25,7 @@
:key="`member-${member.id}`"
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="userLink(member.user.username)"
:target="linkTarget ?? null"
:target="resolveLinkTarget(userLinkTarget)"
>
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
@@ -36,7 +36,7 @@
v-tooltip="formatMessage(messages.owner)"
class="text-brand-orange"
/>
<ExternalIcon v-if="linkTarget === '_blank'" />
<ExternalIcon v-if="resolveLinkTarget(userLinkTarget) === '_blank'" />
</span>
<span class="text-sm font-normal text-secondary">{{ member.role }}</span>
</div>
@@ -79,8 +79,13 @@ const props = defineProps<{
orgLink: (slug: string) => string
userLink: (username: string) => string
linkTarget?: string
userLinkTarget?: string | null
}>()
function resolveLinkTarget(target: string | null | undefined): string | null {
return target === undefined ? (props.linkTarget ?? null) : target
}
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name
const sortedMembers = computed(() => {
@@ -76,7 +76,7 @@ const backupCreator = computed(() => {
const creatorProfileLink = computed(() =>
backupCreator.value && backupCreator.value.id !== 'support'
? `https://modrinth.com/user/${encodeURIComponent(backupCreator.value.username)}`
? `/user/${encodeURIComponent(backupCreator.value.username)}`
: undefined,
)
@@ -224,8 +224,6 @@ const creatorAvatarSrc = computed(() =>
<template v-else-if="backupCreator">
<AutoLink
:to="creatorProfileLink"
:target="creatorProfileLink ? '_blank' : undefined"
:rel="creatorProfileLink ? 'noopener noreferrer' : undefined"
class="group flex min-w-0 items-center gap-1.5"
:class="creatorProfileLink ? 'text-secondary hover:underline' : 'text-primary'"
>
@@ -0,0 +1,295 @@
<template>
<PageHeader :title="user.username" :summary="summary">
<template #leading>
<Avatar
:src="user.avatar_url"
:alt="user.username"
:size="isModrinthUser ? '64px' : '96px'"
:tint-by="user.username"
circle
/>
</template>
<template v-if="isOfficialAccount || showAffiliateBadge" #badges>
<PageHeaderBadgeItem
v-if="isOfficialAccount"
:icon="BadgeCheckIcon"
:icon-props="{ fill: 'var(--color-brand-highlight)' }"
:tooltip="formatMessage(messages.officialAccount)"
class="border-brand-highlight bg-brand-highlight text-brand"
>
{{ formatMessage(messages.officialAccount) }}
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-if="showAffiliateBadge"
:icon="AffiliateIcon"
class="border-brand-highlight bg-brand-highlight text-brand"
>
{{ formatMessage(messages.affiliateLabel) }}
</PageHeaderBadgeItem>
</template>
<template v-if="$slots.summary" #summary>
<slot name="summary" />
</template>
<template v-if="!isModrinthUser" #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataNumberItem
:icon="BoxIcon"
:value="projectsCount"
:label="formatMessage(messages.profileProjectCountLabel, { count: projectsCount })"
/>
<PageHeaderMetadataNumberItem
:icon="DownloadIcon"
:value="downloads"
:label="formatMessage(messages.profileDownloadCountLabel, { count: downloads })"
:tooltip="downloadsTooltip"
/>
<PageHeaderMetadataTimeItem
:icon="CalendarIcon"
:date="user.created"
:label="formatMessage(messages.profileJoinedLabel)"
:tooltip="joinedTooltip"
/>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isSelf" size="large">
<AutoLink :to="editProfileLink">
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</AutoLink>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BadgeCheckIcon,
BoxIcon,
CalendarIcon,
ChartIcon,
ClipboardCopyIcon,
CurrencyIcon,
DownloadIcon,
EditIcon,
InfoIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
import PageHeaderMetadataNumberItem from '#ui/components/base/page-header/metadata/page-header-metadata-number-item.vue'
import PageHeaderMetadataTimeItem from '#ui/components/base/page-header/metadata/page-header-metadata-time-item.vue'
import PageHeaderActions from '#ui/components/base/page-header/page-header-actions.vue'
import PageHeaderBadgeItem from '#ui/components/base/page-header/page-header-badge-item.vue'
import type { Item as TeleportOverflowMenuItem } from '#ui/components/base/TeleportOverflowMenu.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { defineMessages, useFormatDateTime, useFormatNumber, useVIntl } from '#ui/composables'
import type { AuthUser } from '#ui/providers/auth'
import { commonMessages } from '#ui/utils'
const messages = defineMessages({
affiliateLabel: {
id: 'profile.label.affiliate',
defaultMessage: 'Affiliate',
},
analyticsButton: {
id: 'profile.button.analytics',
defaultMessage: 'View user analytics',
},
billingButton: {
id: 'profile.button.billing',
defaultMessage: 'Manage user billing',
},
editRoleButton: {
id: 'profile.button.edit-role',
defaultMessage: 'Edit role',
},
infoButton: {
id: 'profile.button.info',
defaultMessage: 'View user details',
},
officialAccount: {
id: 'profile.official-account',
defaultMessage: 'Official Modrinth account',
},
profileJoinedLabel: {
id: 'profile.label.joined',
defaultMessage: 'Joined',
},
profileProjectCountLabel: {
id: 'profile.label.project-count',
defaultMessage: '{count, plural, one {project} other {projects}}',
},
profileDownloadCountLabel: {
id: 'profile.label.download-count',
defaultMessage: '{count, plural, one {download} other {downloads}}',
},
profileManageProjectsButton: {
id: 'profile.button.manage-projects',
defaultMessage: 'Manage projects',
},
removeAffiliateButton: {
id: 'profile.button.remove-affiliate',
defaultMessage: 'Remove as affiliate',
},
setAffiliateButton: {
id: 'profile.button.set-affiliate',
defaultMessage: 'Set as affiliate',
},
})
const props = withDefaults(
defineProps<{
user: Labrinth.Users.v3.User
summary?: string | null
authUser?: AuthUser | null
editProfileLink?: string | (() => void)
isModrinthUser?: boolean
isOfficialAccount?: boolean
showAffiliateBadge?: boolean
isAffiliate?: boolean
isSelf?: boolean
isAdmin?: boolean
isStaff?: boolean
showStaffActions?: boolean
projectsCount?: number
downloads?: number
}>(),
{
summary: null,
authUser: null,
editProfileLink: '/settings/profile',
isModrinthUser: false,
isOfficialAccount: false,
showAffiliateBadge: false,
isAffiliate: false,
isSelf: false,
isAdmin: false,
isStaff: false,
showStaffActions: false,
projectsCount: 0,
downloads: 0,
},
)
const emit = defineEmits<{
manageProjects: []
report: []
copyId: []
copyPermalink: []
openBilling: []
toggleAffiliate: []
openInfo: []
openAnalytics: []
editRole: []
}>()
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const downloadsTooltip = computed(() => formatNumber(props.downloads))
const joinedTooltip = computed(() => formatDateTime(props.user.created))
const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
{
id: 'manage-projects',
label: formatMessage(messages.profileManageProjectsButton),
icon: BoxIcon,
action: () => emit('manageProjects'),
shown: props.isSelf,
},
{
divider: true,
shown: props.isSelf,
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () => emit('report'),
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => emit('copyId'),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => emit('copyPermalink'),
},
{
divider: true,
shown: props.showStaffActions && (props.isAdmin || props.isStaff),
},
{
id: 'open-billing',
label: formatMessage(messages.billingButton),
icon: CurrencyIcon,
action: () => emit('openBilling'),
shown: props.showStaffActions && props.isStaff,
},
{
id: 'toggle-affiliate',
label: props.isAffiliate
? formatMessage(messages.removeAffiliateButton)
: formatMessage(messages.setAffiliateButton),
icon: AffiliateIcon,
action: () => emit('toggleAffiliate'),
shown: props.showStaffActions && props.isAdmin,
remainOnClick: true,
color: props.isAffiliate ? 'red' : 'orange',
},
{
id: 'open-info',
label: formatMessage(messages.infoButton),
icon: InfoIcon,
action: () => emit('openInfo'),
shown: props.showStaffActions && props.isStaff,
},
{
id: 'open-analytics',
label: formatMessage(messages.analyticsButton),
icon: ChartIcon,
action: () => emit('openAnalytics'),
shown: props.showStaffActions && props.isAdmin,
},
{
id: 'edit-role',
label: formatMessage(messages.editRoleButton),
icon: EditIcon,
action: () => emit('editRole'),
shown: props.showStaffActions && props.isAdmin,
},
])
</script>
+1
View File
@@ -1 +1,2 @@
export { default as UserBadges } from './UserBadges.vue'
export { default as UserPageHeader } from './UserPageHeader.vue'
+1
View File
@@ -4,4 +4,5 @@ export * from './shared/content-tab'
export * from './shared/files-tab'
export * from './shared/installation-settings'
export * from './shared/server-settings'
export * from './shared/user-profile'
export * from './wrapped'
@@ -280,9 +280,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
name: result.organization == null ? result.author : result.organization,
link:
result.organization_id == null
? ctx.variant === 'web'
? `/user/${result.author_id ?? result.author}`
: `https://modrinth.com/user/${result.author_id ?? result.author}`
? `/user/${encodeURIComponent(result.author_id ?? result.author)}`
: ctx.variant === 'web'
? `/organization/${result.organization_id}`
: `https://modrinth.com/organization/${result.organization_id}`,
@@ -260,7 +260,10 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
owner: item.owner
? {
...item.owner,
link: `https://modrinth.com/${item.owner.type}/${item.owner.id}`,
link:
item.owner.type === 'user'
? `/user/${encodeURIComponent(item.owner.id)}`
: `https://modrinth.com/organization/${item.owner.id}`,
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
@@ -0,0 +1,2 @@
export { default as UserProfilePageLayout } from './layout.vue'
export * from './providers'
@@ -0,0 +1,881 @@
<template>
<template v-if="user">
<NewModal
v-if="variant === 'web'"
ref="editRoleModal"
:header="formatMessage(messages.editRoleButton)"
>
<div class="flex w-80 flex-col gap-4">
<Combobox
v-model="selectedRole"
:options="roleOptions"
:placeholder="formatMessage(messages.selectRolePlaceholder)"
/>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button type="button" @click="cancelRoleEdit">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
type="button"
:disabled="!selectedRole || selectedRole === user.role || isSavingRole"
@click="saveRoleEdit"
>
<template v-if="isSavingRole">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.savingLabel) }}
</template>
<template v-else>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</template>
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
v-if="variant === 'web' && isStaffViewing"
ref="userDetailsModal"
:header="formatMessage(messages.userDetailsTitle)"
>
<div class="flex flex-col gap-3">
<div v-if="isAdminViewing" class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">
{{ formatMessage(commonMessages.emailLabel) }}
</span>
<span
v-tooltip="
user.email_verified
? formatMessage(messages.emailVerifiedTooltip)
: formatMessage(messages.emailNotVerifiedTooltip)
"
class="flex w-fit items-center gap-1"
>
<span>{{ user.email }}</span>
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
<XIcon v-else class="h-4 w-4 text-red" />
</span>
</div>
<div v-else class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">
{{ formatMessage(messages.emailVerifiedLabel) }}
</span>
<span class="flex w-fit items-center gap-1">
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
<XIcon v-else class="h-4 w-4 text-red" />
{{
user.email_verified
? formatMessage(commonMessages.yesLabel)
: formatMessage(commonMessages.noLabel)
}}
</span>
</div>
<div v-if="isAdminViewing" class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">
{{ formatMessage(messages.authProvidersLabel) }}
</span>
<span>{{ user.auth_providers?.join(', ') || '—' }}</span>
</div>
<div v-if="isAdminViewing" class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">
{{ formatMessage(messages.paymentMethodsLabel) }}
</span>
<span>
<template v-if="user.payout_data?.paypal_address">
Paypal ({{ user.payout_data.paypal_address }}
<template v-if="user.payout_data.paypal_country">
- {{ user.payout_data.paypal_country }}
</template>
)
</template>
<template v-if="user.payout_data?.paypal_address && user.payout_data?.venmo_handle">
,
</template>
<template v-if="user.payout_data?.venmo_handle">
Venmo ({{ user.payout_data.venmo_handle }})
</template>
<template v-if="!user.payout_data?.paypal_address && !user.payout_data?.venmo_handle">
</template>
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">
{{ formatMessage(messages.hasPasswordLabel) }}
</span>
<span>
{{
user.has_password
? formatMessage(commonMessages.yesLabel)
: formatMessage(commonMessages.noLabel)
}}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">
{{ formatMessage(messages.hasTotpLabel) }}
</span>
<span>
{{
user.has_totp
? formatMessage(commonMessages.yesLabel)
: formatMessage(commonMessages.noLabel)
}}
</span>
</div>
</div>
</NewModal>
<NormalPage :sidebar="sidebarPosition">
<template #header>
<UserPageHeader
:user="user"
:summary="isModrinthUser ? null : profileHeaderSummary"
:auth-user="auth.user.value"
:edit-profile-link="editProfileLink"
:is-modrinth-user="isModrinthUser"
:is-official-account="isOfficialAccount"
:show-affiliate-badge="isAdminViewing && isAffiliate"
:is-affiliate="isAffiliate"
:is-self="isSelf"
:is-admin="isAdminViewing"
:is-staff="isStaffViewing"
:show-staff-actions="variant === 'web'"
:projects-count="projects.length"
:downloads="sumDownloads"
@manage-projects="openPath('/dashboard/projects')"
@report="reportProfile"
@copy-id="copyId"
@copy-permalink="copyPermalink"
@open-billing="openPath(`/admin/billing/${user.id}`)"
@toggle-affiliate="toggleAffiliate"
@open-info="openUserDetails"
@open-analytics="
openPath(`/dashboard/analytics?user=${encodeURIComponent(user.username)}`)
"
@edit-role="openRoleEditModal"
>
<template v-if="isModrinthUser" #summary>
<IntlFormatted :message-id="messages.officialAccountBio">
<template #support-link>
<a
href="https://support.modrinth.com"
class="text-link"
target="_blank"
rel="noopener noreferrer"
>
https://support.modrinth.com
</a>
</template>
<template #email>
<a
href="mailto:support@modrinth.com"
class="text-link"
target="_blank"
rel="noopener noreferrer"
>
support@modrinth.com
</a>
</template>
</IntlFormatted>
</template>
</UserPageHeader>
</template>
<div class="flex flex-col gap-4">
<div v-if="navLinks.length > 2" class="max-w-full overflow-x-auto">
<NavTabs :links="navLinks" replace />
</div>
<div class="flex flex-col gap-3">
<ProjectCardList
v-if="selectedProjectType !== 'collection' && filteredProjects.length > 0"
:layout="displayMode"
>
<ProjectCard
v-for="project in filteredProjects"
:key="project.id"
:link="projectLink(project)"
:title="project.title"
:icon-url="project.icon_url"
:date-updated="project.updated"
:downloads="project.downloads"
:summary="project.description"
:tags="[...project.categories, ...project.loaders]"
:all-tags="[
...project.categories,
...project.loaders,
...project.additional_categories,
]"
:followers="project.followers"
:banner="project.gallery?.find((image) => image.featured)?.url"
:color="project.color"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:layout="displayMode === 'list' ? 'list' : 'grid'"
:status="project.status"
/>
</ProjectCardList>
<EmptyState
v-if="showProjectsEmptyState"
type="empty"
:heading="formatMessage(messages.profileNoProjectsLabel)"
:description="
isSelf ? formatMessage(messages.profileNoProjectsAuthDescription) : undefined
"
>
<template v-if="isSelf" #actions>
<ButtonStyled color="brand">
<button type="button" @click="createProject">
{{ formatMessage(messages.createProjectButton) }}
</button>
</ButtonStyled>
</template>
</EmptyState>
<ProjectCardList
v-if="selectedProjectType === null || selectedProjectType === 'collection'"
layout="grid"
>
<SmartClickable
v-for="collection in sortedCollections"
:key="collection.id"
class="h-full w-full"
>
<template #clickable>
<AutoLink
:to="collectionLink(collection.id)"
class="no-click-animation custom-focus-indicator rounded-xl no-outline"
/>
</template>
<div
class="smart-clickable:outline-on-focus smart-clickable:highlight-on-hover flex h-full w-full flex-col gap-4 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-4 bg-surface-3 p-4 text-left transition-all"
>
<div class="grid grid-cols-[auto_1fr] gap-4">
<Avatar :src="collection.icon_url" size="64px" no-shadow />
<div class="flex min-w-0 flex-col gap-2">
<h2
class="smart-clickable:underline-on-hover m-0 truncate text-lg font-semibold text-contrast"
>
{{ collection.name }}
</h2>
<div class="flex items-center gap-1">
<LibraryIcon aria-hidden="true" />
{{ formatMessage(messages.collectionLabel) }}
</div>
</div>
</div>
<div class="grow text-primary">
{{ collection.description }}
</div>
<div class="mt-auto flex flex-wrap items-center gap-4">
<div class="flex items-center gap-1">
<BoxIcon />
{{
formatMessage(messages.collectionProjectsCount, {
count: collection.projects.length,
})
}}
</div>
<div class="flex items-center gap-1">
<template v-if="collection.status === 'listed'">
<GlobeIcon />
{{ formatMessage(commonMessages.publicLabel) }}
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
{{ formatMessage(commonMessages.unlistedLabel) }}
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
{{ formatMessage(commonMessages.privateLabel) }}
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
{{ formatMessage(commonMessages.rejectedLabel) }}
</template>
</div>
</div>
</div>
</SmartClickable>
</ProjectCardList>
<EmptyState
v-if="showCollectionsEmptyState"
type="empty"
:heading="formatMessage(messages.profileNoCollectionsLabel)"
:description="
isSelf ? formatMessage(messages.profileNoCollectionsAuthDescription) : undefined
"
>
<template v-if="isSelf" #actions>
<ButtonStyled color="brand">
<button type="button" @click="createCollection">
{{ formatMessage(messages.createCollectionButton) }}
</button>
</ButtonStyled>
</template>
</EmptyState>
</div>
</div>
<template #sidebar>
<div class="flex flex-col" :class="{ 'gap-4': variant === 'web' }">
<div v-if="sortedOrganizations.length > 0" :class="sidebarSectionClass">
<h2 class="m-0 mb-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.profileOrganizations) }}
</h2>
<div class="flex flex-wrap gap-2">
<AutoLink
v-for="organization in sortedOrganizations"
:key="organization.id"
v-tooltip="organization.name"
:to="organizationLink(organization.slug)"
link-class="!inline-flex"
>
<Avatar
:src="organization.icon_url"
:alt="`Icon for ${organization.name}`"
size="3rem"
/>
</AutoLink>
</div>
</div>
<UserBadges
:downloads="sumDownloads"
:join-date="new Date(user.created)"
:role="user.role"
:badges="user.badges"
:has-midas="hasMidas"
:has-pride="hasPride26Badge(user)"
:earliest-project-by-type="earliestProjectByType"
:class="sidebarSectionClass"
/>
<slot name="sidebar" />
</div>
</template>
</NormalPage>
</template>
<div v-else class="flex min-h-[24rem] items-center justify-center p-6">
<EmptyState
type="error"
:heading="formatMessage(messages.userNotFoundError)"
:description="formatMessage(messages.userLoadErrorDescription)"
>
<template #actions>
<ButtonStyled color="brand">
<button type="button" @click="retryQueries">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
</template>
</EmptyState>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BoxIcon,
CheckIcon,
GlobeIcon,
LibraryIcon,
LinkIcon,
LockIcon,
SaveIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { UserBadge } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox from '#ui/components/base/Combobox.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import SmartClickable from '#ui/components/base/SmartClickable.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import NormalPage from '#ui/components/page/NormalPage.vue'
import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
import UserBadges from '#ui/components/user/UserBadges.vue'
import UserPageHeader from '#ui/components/user/UserPageHeader.vue'
import { defineMessages, useVIntl } from '#ui/composables'
import { injectAuth, injectNotificationManager, injectPageContext, injectTags } from '#ui/providers'
import { commonMessages, getProjectTypeTitleMessage } from '#ui/utils'
import { injectUserProfile } from './providers'
import {
hasActivePride26Midas,
hasPride26Badge,
projectUserSorting,
resolveProjectType,
} from './utils'
type DisplayMode = 'list' | 'grid' | 'gallery'
type ModalRef = {
show: () => void
hide: () => void
}
type ResolvedProject = Labrinth.Projects.v2.Project & {
resolvedProjectType: string
}
type EarlyAdopterProjectType =
| 'modpack'
| 'resourcepack'
| 'plugin'
| 'datapack'
| 'shader'
| 'server'
const props = withDefaults(
defineProps<{
userId: string
projectType?: string
displayMode?: DisplayMode
sidebarPosition?: 'left' | 'right'
variant?: 'web' | 'app'
siteUrl?: string
externalNavigation?: boolean
projectLinkMode?: 'website' | 'app'
onCreateProject?: (event?: MouseEvent) => void
onCreateCollection?: (event?: MouseEvent) => void
}>(),
{
projectType: undefined,
displayMode: 'list',
sidebarPosition: 'right',
variant: 'web',
siteUrl: 'https://modrinth.com',
externalNavigation: false,
projectLinkMode: 'website',
onCreateProject: undefined,
onCreateCollection: undefined,
},
)
const userProfile = injectUserProfile()
const auth = injectAuth()
const tags = injectTags(null)
const pageContext = injectPageContext()
const notificationManager = injectNotificationManager()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const { formatMessage } = useVIntl()
const sidebarSectionClass = computed(() =>
props.variant === 'app'
? 'border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4'
: 'rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4',
)
const messages = defineMessages({
collectionProjectsCount: {
id: 'profile.collection.projects-count',
defaultMessage: '{count, plural, one {# project} other {# projects}}',
},
savingLabel: {
id: 'profile.label.saving',
defaultMessage: 'Saving...',
},
editRoleButton: {
id: 'profile.button.edit-role',
defaultMessage: 'Edit role',
},
selectRolePlaceholder: {
id: 'profile.role.select-placeholder',
defaultMessage: 'Select a role',
},
userDetailsTitle: {
id: 'profile.details.title',
defaultMessage: 'User details',
},
emailVerifiedLabel: {
id: 'profile.details.label.email-verified',
defaultMessage: 'Email verified',
},
emailVerifiedTooltip: {
id: 'profile.details.tooltip.email-verified',
defaultMessage: 'Email verified',
},
emailNotVerifiedTooltip: {
id: 'profile.details.tooltip.email-not-verified',
defaultMessage: 'Email not verified',
},
authProvidersLabel: {
id: 'profile.details.label.auth-providers',
defaultMessage: 'Auth providers',
},
paymentMethodsLabel: {
id: 'profile.details.label.payment-methods',
defaultMessage: 'Payment methods',
},
hasPasswordLabel: {
id: 'profile.details.label.has-password',
defaultMessage: 'Has password',
},
hasTotpLabel: {
id: 'profile.details.label.has-totp',
defaultMessage: 'Has TOTP',
},
bioFallbackUser: {
id: 'profile.bio.fallback.user',
defaultMessage: 'A Modrinth user.',
},
bioFallbackCreator: {
id: 'profile.bio.fallback.creator',
defaultMessage: 'A Modrinth creator.',
},
collectionLabel: {
id: 'profile.label.collection',
defaultMessage: 'Collection',
},
collectionsLabel: {
id: 'project-type.collection.plural',
defaultMessage: 'Collections',
},
profileOrganizations: {
id: 'profile.label.organizations',
defaultMessage: 'Organizations',
},
profileNoProjectsLabel: {
id: 'profile.label.no-projects',
defaultMessage: 'This user has no projects!',
},
profileNoProjectsAuthDescription: {
id: 'profile.label.no-projects-auth-description',
defaultMessage: "You don't have any projects yet.",
},
createProjectButton: {
id: 'profile.button.create-project',
defaultMessage: 'Create a project',
},
profileNoCollectionsLabel: {
id: 'profile.label.no-collections',
defaultMessage: 'This user has no collections!',
},
profileNoCollectionsAuthDescription: {
id: 'profile.label.no-collections-auth-description',
defaultMessage: "You don't have any collections yet.",
},
createCollectionButton: {
id: 'profile.button.create-collection',
defaultMessage: 'Create a collection',
},
userNotFoundError: {
id: 'profile.error.not-found',
defaultMessage: 'User not found',
},
userLoadErrorDescription: {
id: 'profile.error.load-description',
defaultMessage: 'The user profile could not be loaded.',
},
officialAccountBio: {
id: 'profile.official-account.bio',
defaultMessage:
'The official user account of Modrinth. Get support at <support-link></support-link> or via email at <email></email>',
},
roleUpdateErrorTitle: {
id: 'profile.role.update-error-title',
defaultMessage: 'Failed to update role',
},
roleUpdateErrorDescription: {
id: 'profile.role.update-error-description',
defaultMessage: 'An error occurred while updating the user role. Please try again.',
},
})
const userQuery = useQuery({
queryKey: computed(() => ['user', props.userId]),
queryFn: () => userProfile.getUser(props.userId),
enabled: computed(() => Boolean(props.userId)),
staleTime: 30_000,
})
const projectsQuery = useQuery({
queryKey: computed(() => ['user', props.userId, 'projects']),
queryFn: () => userProfile.getProjects(props.userId),
enabled: computed(() => Boolean(props.userId)),
staleTime: 30_000,
})
const organizationsQuery = useQuery({
queryKey: computed(() => ['user', props.userId, 'organizations']),
queryFn: () => userProfile.getOrganizations(props.userId),
enabled: computed(() => Boolean(props.userId)),
staleTime: 30_000,
})
const collectionsQuery = useQuery({
queryKey: computed(() => ['user', props.userId, 'collections']),
queryFn: () => userProfile.getCollections(props.userId),
enabled: computed(() => Boolean(props.userId)),
staleTime: 30_000,
})
const user = computed(() => userQuery.data.value)
const projects = computed<ResolvedProject[]>(() =>
(projectsQuery.data.value ?? []).map((project) => ({
...project,
resolvedProjectType: resolveProjectType(project, tags?.loaders.value ?? []),
})),
)
const organizations = computed(() => organizationsQuery.data.value ?? [])
const collections = computed(() => collectionsQuery.data.value ?? [])
const selectedProjectType = computed(() => {
const projectType = props.projectType
if (!projectType) return null
if (projectType === 'collections' || projectType === 'collection') return 'collection'
return projectType.endsWith('s') ? projectType.slice(0, -1) : projectType
})
const filteredProjects = computed(() => {
const selected = selectedProjectType.value
return projects.value
.filter((project) => !selected || project.resolvedProjectType === selected)
.slice()
.sort(projectUserSorting)
})
const sortedOrganizations = computed(() =>
organizations.value.slice().sort((first, second) => first.name.localeCompare(second.name)),
)
const sortedCollections = computed(() =>
collections.value.slice().sort((first, second) => {
const updatedDifference = new Date(second.updated).getTime() - new Date(first.updated).getTime()
if (updatedDifference !== 0) return updatedDifference
return new Date(second.created).getTime() - new Date(first.created).getTime()
}),
)
const projectTypes = computed(() => {
const types = new Set(projects.value.map((project) => project.resolvedProjectType))
if (collections.value.length > 0) types.add('collection')
types.delete('project')
return [...types]
})
const navLinks = computed(() => {
if (!user.value) return []
const profilePath = `/user/${encodeURIComponent(props.userId)}`
return [
{
label: formatMessage(commonMessages.allProjectType),
href: profilePath,
},
...projectTypes.value
.map((projectType) => ({
label:
projectType === 'collection'
? formatMessage(messages.collectionsLabel)
: formatMessage(getProjectTypeTitleMessage(projectType), { count: 2 }),
href: `${profilePath}/${projectType}s`,
}))
.sort((first, second) => first.label.localeCompare(second.label)),
]
})
const sumDownloads = computed(() =>
projects.value.reduce((total, project) => total + project.downloads, 0),
)
const profileHeaderSummary = computed(() => {
if (!user.value) return ''
if (user.value.bio) return user.value.bio
return projects.value.length === 0
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator)
})
const earliestProjectByType = computed(() => {
const earliest = {} as Record<EarlyAdopterProjectType, Date>
for (const project of projects.value) {
const projectType = project.resolvedProjectType as EarlyAdopterProjectType
const published = new Date(project.published)
if (!earliest[projectType] || published < earliest[projectType]) {
earliest[projectType] = published
}
}
return earliest
})
const isModrinthUser = computed(() => user.value?.id === '2REoufqX')
const isOfficialAccount = computed(() => isModrinthUser.value || user.value?.id === 'GVFjtWTf')
const isSelf = computed(() => auth.user.value?.id === user.value?.id)
const isAdminViewing = computed(() => auth.user.value?.role === 'admin')
const isStaffViewing = computed(
() => auth.user.value?.role === 'admin' || auth.user.value?.role === 'moderator',
)
const isAffiliate = computed(() => Boolean((user.value?.badges ?? 0) & UserBadge.AFFILIATE))
const hasMidas = computed(
() => Boolean((user.value?.badges ?? 0) & UserBadge.MIDAS) || hasActivePride26Midas(user.value),
)
const showProjectsEmptyState = computed(
() =>
selectedProjectType.value !== 'collection' &&
filteredProjects.value.length === 0 &&
(selectedProjectType.value !== null || collections.value.length === 0),
)
const showCollectionsEmptyState = computed(
() => selectedProjectType.value === 'collection' && collections.value.length === 0,
)
const normalizedSiteUrl = computed(() => props.siteUrl.replace(/\/$/, ''))
const editProfileLink = computed(() => linkTarget('/settings/profile'))
function externalUrl(path: string): string {
return `${normalizedSiteUrl.value}${path.startsWith('/') ? path : `/${path}`}`
}
function linkTarget(path: string): string | (() => void) {
if (!props.externalNavigation) return path
return () => pageContext.openExternalUrl(externalUrl(path))
}
function openPath(path: string): void {
const target = linkTarget(path)
if (typeof target === 'function') {
target()
} else {
void router.push(target)
}
}
function projectLink(project: ResolvedProject): string | (() => void) {
if (props.projectLinkMode === 'app') {
return `/project/${project.id}`
}
return `/${project.resolvedProjectType}/${project.slug || project.id}`
}
function organizationLink(slug: string): string | (() => void) {
return linkTarget(`/organization/${encodeURIComponent(slug)}`)
}
function collectionLink(id: string): string | (() => void) {
return linkTarget(`/collection/${encodeURIComponent(id)}`)
}
async function copyId(): Promise<void> {
if (user.value) await navigator.clipboard.writeText(user.value.id)
}
async function copyPermalink(): Promise<void> {
if (user.value) {
await navigator.clipboard.writeText(externalUrl(`/user/${user.value.id}`))
}
}
function reportProfile(): void {
if (!user.value) return
const reportPath = `/report?item=user&itemID=${encodeURIComponent(user.value.id)}`
if (props.externalNavigation) {
pageContext.openExternalUrl(externalUrl(reportPath))
} else if (auth.user.value) {
void router.push(reportPath)
} else {
void auth.requestSignIn(route.fullPath)
}
}
function createProject(event?: MouseEvent): void {
if (props.onCreateProject) {
props.onCreateProject(event)
} else {
openPath('/dashboard/projects')
}
}
function createCollection(event?: MouseEvent): void {
if (props.onCreateCollection) {
props.onCreateCollection(event)
} else {
openPath('/dashboard/collections')
}
}
async function retryQueries(): Promise<void> {
await Promise.allSettled([
userQuery.refetch(),
projectsQuery.refetch(),
organizationsQuery.refetch(),
collectionsQuery.refetch(),
])
}
const userDetailsModal = ref<ModalRef | null>(null)
const editRoleModal = ref<ModalRef | null>(null)
const selectedRole = ref<Labrinth.Users.v3.Role | null>(null)
const isSavingRole = ref(false)
const roleOptions = [
{ value: 'developer', label: 'Developer' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'admin', label: 'Admin' },
] satisfies { value: Labrinth.Users.v3.Role; label: string }[]
watch(
user,
(currentUser) => {
selectedRole.value = currentUser?.role ?? null
},
{ immediate: true },
)
function openUserDetails(): void {
userDetailsModal.value?.show()
}
function openRoleEditModal(): void {
selectedRole.value = user.value?.role ?? null
editRoleModal.value?.show()
}
function cancelRoleEdit(): void {
selectedRole.value = user.value?.role ?? null
editRoleModal.value?.hide()
}
async function toggleAffiliate(): Promise<void> {
if (!user.value) return
await userProfile.patchUser(user.value.id, {
badges: user.value.badges ^ UserBadge.AFFILIATE,
})
await queryClient.invalidateQueries({ queryKey: ['user', props.userId] })
}
async function saveRoleEdit(): Promise<void> {
if (!user.value || !selectedRole.value || selectedRole.value === user.value.role) return
isSavingRole.value = true
try {
await userProfile.patchUser(user.value.id, { role: selectedRole.value })
await queryClient.invalidateQueries({ queryKey: ['user', props.userId] })
editRoleModal.value?.hide()
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.roleUpdateErrorTitle),
text: formatMessage(messages.roleUpdateErrorDescription),
})
} finally {
isSavingRole.value = false
}
}
</script>
@@ -0,0 +1 @@
export * from './user-profile'
@@ -0,0 +1,19 @@
import type { Labrinth } from '@modrinth/api-client'
import { createContext } from '#ui/providers/create-context'
export interface UserProfileContext {
getUser: (userId: string) => Promise<Labrinth.Users.v3.User>
getProjects: (userId: string) => Promise<Labrinth.Projects.v2.Project[]>
getOrganizations: (userId: string) => Promise<Labrinth.Organizations.v3.Organization[]>
getCollections: (userId: string) => Promise<Labrinth.Collections.Collection[]>
patchUser: (
userId: string,
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
) => Promise<void>
}
export const [injectUserProfile, provideUserProfile] = createContext<UserProfileContext>(
'UserProfilePageLayout',
'userProfileContext',
)
@@ -0,0 +1,87 @@
import type { Labrinth } from '@modrinth/api-client'
type ProjectSorting = 'publish_time' | 'queue_time' | 'downloads'
type ProjectStatusPriority = { order: number; sort: ProjectSorting }
const projectStatusPriority: Record<Labrinth.Projects.v2.ProjectStatus, ProjectStatusPriority> = {
approved: { order: 1, sort: 'downloads' },
scheduled: { order: 1, sort: 'downloads' },
archived: { order: 2, sort: 'downloads' },
unlisted: { order: 3, sort: 'downloads' },
private: { order: 4, sort: 'downloads' },
processing: { order: 5, sort: 'queue_time' },
withheld: { order: 6, sort: 'publish_time' },
rejected: { order: 7, sort: 'publish_time' },
draft: { order: 8, sort: 'publish_time' },
unknown: { order: 9, sort: 'publish_time' },
}
function getProjectSortValue(
project: Labrinth.Projects.v2.Project,
sorting: ProjectSorting,
): number {
switch (sorting) {
case 'publish_time':
return new Date(project.published).getTime()
case 'queue_time':
return new Date(project.queued || project.published).getTime()
case 'downloads':
return project.downloads
}
}
export function projectUserSorting(
first: Labrinth.Projects.v2.Project,
second: Labrinth.Projects.v2.Project,
): number {
const firstPriority = projectStatusPriority[first.status] ?? projectStatusPriority.unknown
const secondPriority = projectStatusPriority[second.status] ?? projectStatusPriority.unknown
if (firstPriority.order !== secondPriority.order) {
return firstPriority.order - secondPriority.order
}
if (firstPriority.sort !== secondPriority.sort) {
return 0
}
return (
getProjectSortValue(second, secondPriority.sort) -
getProjectSortValue(first, firstPriority.sort)
)
}
export function resolveProjectType(
project: Labrinth.Projects.v2.Project,
loaders: Labrinth.Tags.v2.Loader[],
): string {
if (project.project_type !== 'mod') {
return project.project_type
}
const projectLoaders = new Set(project.loaders)
const supportsType = (type: string) =>
loaders.some(
(loader) => projectLoaders.has(loader.name) && loader.supported_project_types.includes(type),
)
if (supportsType('datapack')) return 'datapack'
if (supportsType('plugin')) return 'plugin'
return 'mod'
}
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
export function hasPride26Badge(user?: Labrinth.Users.v3.User | null): boolean {
return user?.campaigns?.pride_26?.has_badge === true
}
export function hasActivePride26Midas(
user?: Labrinth.Users.v3.User | null,
now = Date.now(),
): boolean {
const campaign = user?.campaigns?.pride_26
if (!campaign?.has_midas) return false
const donatedAt = Date.parse(campaign.last_donated_at)
return Number.isFinite(donatedAt) && donatedAt + PRIDE_26_MIDAS_DURATION_MS > now
}
+117
View File
@@ -2849,9 +2849,123 @@
"payment-method.visa": {
"defaultMessage": "Visa"
},
"profile.bio.fallback.creator": {
"defaultMessage": "A Modrinth creator."
},
"profile.bio.fallback.user": {
"defaultMessage": "A Modrinth user."
},
"profile.button.analytics": {
"defaultMessage": "View user analytics"
},
"profile.button.billing": {
"defaultMessage": "Manage user billing"
},
"profile.button.create-collection": {
"defaultMessage": "Create a collection"
},
"profile.button.create-project": {
"defaultMessage": "Create a project"
},
"profile.button.edit-role": {
"defaultMessage": "Edit role"
},
"profile.button.info": {
"defaultMessage": "View user details"
},
"profile.button.manage-projects": {
"defaultMessage": "Manage projects"
},
"profile.button.remove-affiliate": {
"defaultMessage": "Remove as affiliate"
},
"profile.button.set-affiliate": {
"defaultMessage": "Set as affiliate"
},
"profile.collection.projects-count": {
"defaultMessage": "{count, plural, one {# project} other {# projects}}"
},
"profile.details.label.auth-providers": {
"defaultMessage": "Auth providers"
},
"profile.details.label.email-verified": {
"defaultMessage": "Email verified"
},
"profile.details.label.has-password": {
"defaultMessage": "Has password"
},
"profile.details.label.has-totp": {
"defaultMessage": "Has TOTP"
},
"profile.details.label.payment-methods": {
"defaultMessage": "Payment methods"
},
"profile.details.title": {
"defaultMessage": "User details"
},
"profile.details.tooltip.email-not-verified": {
"defaultMessage": "Email not verified"
},
"profile.details.tooltip.email-verified": {
"defaultMessage": "Email verified"
},
"profile.error.load-description": {
"defaultMessage": "The user profile could not be loaded."
},
"profile.error.not-found": {
"defaultMessage": "User not found"
},
"profile.label.affiliate": {
"defaultMessage": "Affiliate"
},
"profile.label.badges": {
"defaultMessage": "Badges"
},
"profile.label.collection": {
"defaultMessage": "Collection"
},
"profile.label.download-count": {
"defaultMessage": "{count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"defaultMessage": "Joined"
},
"profile.label.no-collections": {
"defaultMessage": "This user has no collections!"
},
"profile.label.no-collections-auth-description": {
"defaultMessage": "You don't have any collections yet."
},
"profile.label.no-projects": {
"defaultMessage": "This user has no projects!"
},
"profile.label.no-projects-auth-description": {
"defaultMessage": "You don't have any projects yet."
},
"profile.label.organizations": {
"defaultMessage": "Organizations"
},
"profile.label.project-count": {
"defaultMessage": "{count, plural, one {project} other {projects}}"
},
"profile.label.saving": {
"defaultMessage": "Saving..."
},
"profile.official-account": {
"defaultMessage": "Official Modrinth account"
},
"profile.official-account.bio": {
"defaultMessage": "The official user account of Modrinth. Get support at <support-link></support-link> or via email at <email></email>"
},
"profile.role.select-placeholder": {
"defaultMessage": "Select a role"
},
"profile.role.update-error-description": {
"defaultMessage": "An error occurred while updating the user role. Please try again."
},
"profile.role.update-error-title": {
"defaultMessage": "Failed to update role"
},
"project-card.date.published.tooltip": {
"defaultMessage": "Published {date}"
},
@@ -2879,6 +2993,9 @@
"project-type.all": {
"defaultMessage": "All"
},
"project-type.collection.plural": {
"defaultMessage": "Collections"
},
"project-type.datapack.capital": {
"defaultMessage": "{count, plural, one {Data Pack} other {Data Packs}}"
},