feat: migrate all headers to use PageHeader shared component.

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:22 +01:00
parent 8493e66137
commit de007985c1
20 changed files with 1455 additions and 1145 deletions
@@ -0,0 +1,127 @@
<template>
<ButtonStyled size="large" circular>
<PopoutMenu
v-if="authUser"
:tooltip="
saved ? formatMessage(commonMessages.savedLabel) : formatMessage(commonMessages.saveButton)
"
from="top-right"
:aria-label="formatMessage(commonMessages.saveButton)"
:dropdown-id="`${baseId}-save`"
>
<BookmarkIcon aria-hidden="true" :fill="saved ? 'currentColor' : 'none'" />
<template #menu>
<StyledInput
v-model="displayCollectionsSearch"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
wrapper-class="menu-search"
/>
<div v-if="filteredCollections.length > 0" class="collections-list text-primary">
<Checkbox
v-for="option in filteredCollections"
:key="option.id"
:model-value="option.projects.includes(projectId)"
class="popout-checkbox"
@update:model-value="() => collectProject(option, projectId)"
>
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">{{ noCollectionsLabel }}</p>
</div>
<ButtonStyled>
<button class="mx-3 mb-3" @click="createCollection">
<PlusIcon aria-hidden="true" />
{{ createNewCollectionLabel }}
</button>
</ButtonStyled>
</template>
</PopoutMenu>
<nuxt-link
v-else
v-tooltip="formatMessage(commonMessages.saveButton)"
:to="signInRoute"
:aria-label="formatMessage(commonMessages.saveButton)"
>
<BookmarkIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
</template>
<script setup lang="ts">
import { BookmarkIcon, PlusIcon } from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
commonMessages,
PopoutMenu,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
type CollectionOption = {
id: string
name: string
projects: string[]
}
const props = defineProps<{
authUser?: unknown
signInRoute: RouteLocationRaw
projectId: string
collections: CollectionOption[]
saved: boolean
baseId: string
noCollectionsLabel: string
createNewCollectionLabel: string
collectProject: (option: CollectionOption, projectId: string) => void | Promise<void>
createCollection: (event: MouseEvent) => void
}>()
const { formatMessage } = useVIntl()
const displayCollectionsSearch = ref('')
const filteredCollections = computed(() =>
props.collections
.filter((collection) =>
collection.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
)
.slice()
.sort((a, b) => a.name.localeCompare(b.name)),
)
</script>
<style scoped lang="scss">
.popout-checkbox {
padding: var(--gap-sm) var(--gap-md);
white-space: nowrap;
&:hover {
filter: brightness(0.95);
}
}
.menu-text {
padding: 0 var(--gap-md);
font-size: var(--font-size-nm);
color: var(--color-secondary);
}
.menu-search {
margin: var(--gap-sm) var(--gap-md);
width: calc(100% - var(--gap-md) * 2);
}
.collections-list {
max-height: 40rem;
overflow-y: auto;
background-color: var(--color-bg);
border-radius: var(--radius-md);
margin: var(--gap-sm) var(--gap-md);
padding: var(--gap-sm);
}
</style>
@@ -1964,6 +1964,9 @@
"discover.install.heading.reset-modpack": {
"message": "Selecting modpack to install after reset"
},
"discover.install.world-fallback-name": {
"message": "Instance"
},
"discover.seo.description": {
"message": "Search and browse thousands of Minecraft {projectType} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType}."
},
@@ -3941,6 +3944,9 @@
"servers.manage.instances.meta.title": {
"message": "Instances - {server} - Modrinth"
},
"servers.manage.instances.slot-name": {
"message": "Instance #{index}"
},
"servers.notice.actions": {
"message": "Actions"
},
@@ -50,8 +50,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
const content = await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId, worldId],
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId, { from_modpack: false }),
queryFn: () => client.archon.content_v1.getAddons(serverId, worldId, { from_modpack: false }),
staleTime: 30_000,
})
+183 -366
View File
@@ -459,358 +459,8 @@
:project="project"
:project-v3="projectV3"
:member="!!currentMember"
>
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand" circular>
<nuxt-link
v-tooltip="'Edit project'"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold lg:!hidden"
>
<SettingsIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold max-lg:!hidden"
>
<SettingsIcon aria-hidden="true" />
Edit project
</nuxt-link>
</ButtonStyled>
<div class="hidden sm:contents">
<ButtonStyled
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
:circular="!!auth.user && !!currentMember"
>
<button
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
{{
auth.user && currentMember ? '' : formatMessage(commonMessages.downloadButton)
}}
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
:circular="!!auth.user && !!currentMember"
>
<button
v-tooltip="auth.user && currentMember && !openInAppModal?.open ? 'Play' : ''"
@click="handlePlayServerProject"
>
<PlayIcon aria-hidden="true" />
{{ auth.user && currentMember ? '' : 'Play' }}
</button>
</ButtonStyled>
</div>
<div class="contents sm:hidden">
<ButtonStyled
v-if="!isServerProject"
size="large"
circular
:color="
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
>
<button
:aria-label="formatMessage(commonMessages.downloadButton)"
class="flex sm:hidden"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
circular
:color="
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
>
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
<PlayIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
theme="dismissable-prompt"
:triggers="[]"
:shown="flags.showProjectPageCreateServersTooltip"
:auto-hide="false"
placement="bottom-start"
>
<ButtonStyled size="large" circular>
<nuxt-link
v-tooltip="formatMessage(messages.createServerTooltip)"
:to="`/hosting?project=${project.id}#plan`"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
}
"
>
<ServerPlusIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-60 items-center justify-between gap-4">
<h3
class="m-0 flex items-center gap-2 whitespace-nowrap text-base font-bold text-contrast"
>
{{ formatMessage(messages.serversPromoTitle) }}
<TagItem
:style="{
'--_color': 'var(--color-brand)',
'--_bg-color': 'var(--color-brand-highlight)',
}"
>{{ formatMessage(commonMessages.newBadge) }}</TagItem
>
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dontShowAgain)"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
}
"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.serversPromoDescription) }}
</p>
<p class="m-0 text-wrap text-sm font-bold text-primary">
<IntlFormatted
:message-id="messages.serversPromoPricing"
:values="{
price: formatPrice(500, 'USD', true),
}"
>
<template #small="{ children }">
<span class="text-xs">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</p>
</div>
</template>
</Tooltip>
<ButtonStyled size="large" circular>
<ClientOnly>
<button
v-if="auth.user"
v-tooltip="
following
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton)
"
:aria-label="
following
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton)
"
@click="userFollowProject(project)"
>
<HeartIcon :fill="following ? 'currentColor' : 'none'" aria-hidden="true" />
</button>
<nuxt-link
v-else
v-tooltip="formatMessage(commonMessages.followButton)"
:to="signInRouteObj"
:aria-label="formatMessage(commonMessages.followButton)"
>
<HeartIcon aria-hidden="true" />
</nuxt-link>
<template #fallback>
<nuxt-link
v-tooltip="formatMessage(commonMessages.followButton)"
:to="signInRouteObj"
:aria-label="formatMessage(commonMessages.followButton)"
>
<HeartIcon aria-hidden="true" />
</nuxt-link>
</template>
</ClientOnly>
</ButtonStyled>
<ButtonStyled size="large" circular>
<PopoutMenu
v-if="auth.user"
:tooltip="
collections.some((x) => x.projects.includes(project.id))
? formatMessage(commonMessages.savedLabel)
: formatMessage(commonMessages.saveButton)
"
from="top-right"
:aria-label="formatMessage(commonMessages.saveButton)"
:dropdown-id="`${baseId}-save`"
>
<BookmarkIcon
aria-hidden="true"
:fill="
collections.some((x) => x.projects.includes(project.id))
? 'currentColor'
: 'none'
"
/>
<template #menu>
<StyledInput
v-model="displayCollectionsSearch"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
wrapper-class="menu-search"
/>
<div v-if="collections.length > 0" class="collections-list text-primary">
<Checkbox
v-for="option in collections
.slice()
.sort((a, b) => a.name.localeCompare(b.name))"
:key="option.id"
:model-value="option.projects.includes(project.id)"
class="popout-checkbox"
@update:model-value="() => onUserCollectProject(option, project.id)"
>
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">{{ formatMessage(messages.noCollectionsFound) }}</p>
</div>
<ButtonStyled>
<button
class="mx-3 mb-3"
@click="(event) => $refs.modal_collection.show(event)"
>
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNewCollection) }}
</button>
</ButtonStyled>
</template>
</PopoutMenu>
<nuxt-link v-else v-tooltip="'Save'" :to="signInRouteObj" aria-label="Save">
<BookmarkIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
{
id: 'analytics',
link: `/${project.project_type}/${project.slug ? project.slug : project.id}/settings/analytics`,
hoverOnly: true,
shown: auth.user && !!currentMember,
},
{
divider: true,
shown: auth.user && !!currentMember,
},
{
id: 'moderation-checklist',
action: openModerationChecklistFromMenu,
color: 'orange',
hoverOnly: true,
shown:
auth.user &&
tags.staffRoles.includes(auth.user.role) &&
!showModerationChecklist,
},
{
divider: true,
shown:
auth.user &&
tags.staffRoles.includes(auth.user.role) &&
!showModerationChecklist,
},
{
id: 'tech-review',
link: `/moderation/technical-review/${project.id}`,
color: 'orange',
hoverOnly: true,
shown: auth.user && tags.staffRoles.includes(auth.user.role),
},
{
divider: true,
shown: auth.user && tags.staffRoles.includes(auth.user.role),
},
{
id: 'report',
action: () =>
auth.user
? reportProject(project.id)
: navigateTo(
getSignInRouteObj(route, getReportPath('project', project.id)),
),
color: 'red',
hoverOnly: true,
shown: !isMember,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
:dropdown-id="`${baseId}-more-options`"
>
<MoreVerticalIcon aria-hidden="true" />
<template #analytics>
<ChartIcon aria-hidden="true" />
{{ formatMessage(commonMessages.analyticsButton) }}
</template>
<template #moderation-checklist>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
</template>
<template #tech-review> <ScanEyeIcon aria-hidden="true" /> Tech review </template>
<template #report>
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
:actions="projectHeaderActions"
/>
<ProjectMemberHeader
v-if="currentMember"
:project="project"
@@ -1044,7 +694,6 @@
<script setup>
import {
BookmarkIcon,
BookTextIcon,
CalendarIcon,
ChartIcon,
@@ -1060,7 +709,6 @@ import {
ModrinthIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
ScanEyeIcon,
@@ -1069,7 +717,6 @@ import {
SettingsIcon,
VersionIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
@@ -1081,12 +728,9 @@ import {
getTagMessage,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NavTabs,
NewModal,
OpenInAppModal,
OverflowMenu,
PopoutMenu,
PROJECT_DEP_MARKER_QUERY,
ProjectBackgroundGradient,
ProjectEnvironmentModal,
@@ -1101,7 +745,6 @@ import {
ScrollablePanel,
ServersPromo,
StyledInput,
TagItem,
useDebugLogger,
useFormatDateTime,
useFormatPrice,
@@ -1113,7 +756,6 @@ import { capitalizeString, formatProjectType, renderString } from '@modrinth/uti
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
import { navigateTo } from '#app'
@@ -1124,6 +766,7 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
@@ -1202,6 +845,7 @@ const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.valu
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const modalCollection = useTemplateRef('modal_collection')
const baseId = useId()
@@ -1632,13 +1276,8 @@ const filteredAlpha = computed(() => {
)
})
const displayCollectionsSearch = ref('')
const collections = computed(() =>
user.value && user.value.collections
? user.value.collections.filter((x) =>
x.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
)
: [],
user.value && user.value.collections ? user.value.collections : [],
)
if (
@@ -2362,6 +2001,184 @@ const canCreateServerFrom = computed(() => {
return project.value.project_type === 'modpack' && project.value.server_side !== 'unsupported'
})
const projectHeaderActions = computed(() => {
if (!project.value) return []
const projectPath = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const hasMember = !!currentMember.value
const userSignedIn = !!auth.value.user
const mutedPrimaryAction = hasMember || route.name === 'type-project-version-version'
const primaryLabel = isServerProject.value ? 'Play' : formatMessage(commonMessages.downloadButton)
return [
...(userSignedIn && hasMember
? [
{
id: 'edit-project',
label: 'Edit project',
icon: SettingsIcon,
color: 'brand',
to: `${projectPath}/settings`,
},
]
: []),
{
id: isServerProject.value ? 'play' : 'download',
label: primaryLabel,
icon: isServerProject.value ? PlayIcon : DownloadIcon,
color: mutedPrimaryAction ? 'standard' : 'brand',
labelHidden: userSignedIn && hasMember,
tooltip: userSignedIn && hasMember ? primaryLabel : undefined,
onClick: (event) => {
if (isServerProject.value) {
handlePlayServerProject()
} else {
downloadModal.value?.show(event)
}
},
},
...(canCreateServerFrom.value && flags.value.showProjectPageQuickServerButton
? [
{
id: 'create-server',
label: formatMessage(messages.serversPromoTitle),
icon: ServerPlusIcon,
labelHidden: true,
tooltip: formatMessage(messages.createServerTooltip),
to: `/hosting?project=${project.value.id}#plan`,
onClick: () => {
flags.value.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
},
prompt: {
title: formatMessage(messages.serversPromoTitle),
description: formatMessage(messages.serversPromoDescription),
badge: formatMessage(commonMessages.newBadge),
footer: formatMessage(messages.serversPromoPricing, {
price: formatPrice(500, 'USD', true),
small: (children) => (Array.isArray(children) ? children.join('') : children),
}),
dismissLabel: formatMessage(messages.dontShowAgain),
shown: flags.value.showProjectPageCreateServersTooltip,
placement: 'bottom-start',
onDismiss: () => {
flags.value.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
},
},
},
]
: []),
{
id: 'follow',
label: following.value
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton),
icon: HeartIcon,
iconProps: {
fill: following.value ? 'currentColor' : 'none',
},
labelHidden: true,
tooltip: following.value
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton),
to: userSignedIn ? undefined : signInRouteObj.value,
onClick: userSignedIn ? () => userFollowProject(project.value) : undefined,
},
{
id: 'save',
label: formatMessage(commonMessages.saveButton),
component: ProjectCollectionSaveButton,
componentProps: {
authUser: auth.value.user,
signInRoute: signInRouteObj.value,
projectId: project.value.id,
collections: collections.value,
saved: collections.value.some((x) => x.projects.includes(project.value.id)),
baseId,
noCollectionsLabel: formatMessage(messages.noCollectionsFound),
createNewCollectionLabel: formatMessage(messages.createNewCollection),
collectProject: onUserCollectProject,
createCollection: (event) => modalCollection.value?.show(event),
},
},
{
id: 'more',
label: formatMessage(commonMessages.moreOptionsButton),
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent',
tooltip: formatMessage(commonMessages.moreOptionsButton),
menuActions: [
{
id: 'analytics',
label: formatMessage(commonMessages.analyticsButton),
icon: ChartIcon,
link: `${projectPath}/settings/analytics`,
shown: userSignedIn && hasMember,
},
{
divider: true,
shown: userSignedIn && hasMember,
},
{
id: 'moderation-checklist',
label: formatMessage(messages.reviewProject),
icon: ScaleIcon,
action: openModerationChecklistFromMenu,
color: 'orange',
shown:
userSignedIn &&
tags.value.staffRoles.includes(auth.value.user.role) &&
!showModerationChecklist.value,
},
{
divider: true,
shown:
userSignedIn &&
tags.value.staffRoles.includes(auth.value.user.role) &&
!showModerationChecklist.value,
},
{
id: 'tech-review',
label: 'Tech review',
icon: ScanEyeIcon,
link: `/moderation/technical-review/${project.value.id}`,
color: 'orange',
shown: userSignedIn && tags.value.staffRoles.includes(auth.value.user.role),
},
{
divider: true,
shown: userSignedIn && tags.value.staffRoles.includes(auth.value.user.role),
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () =>
auth.value.user
? reportProject(project.value.id)
: navigateTo(getSignInRouteObj(route, getReportPath('project', project.value.id))),
color: 'red',
shown: !isMember.value,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => copyId(),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => copyPermalink(),
},
],
},
]
})
const createCanonicalUrl = () =>
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
@@ -62,86 +62,14 @@
</template>
<template v-else>
<div class="normal-page__header py-4">
<ContentPageHeader>
<template #icon>
<Avatar :src="organization.icon_url" :alt="organization.name" size="96px" />
</template>
<template #title>
{{ organization.name }}
</template>
<template #title-suffix>
<div class="ml-1 flex items-center gap-2 font-semibold">
<OrganizationIcon />
Organization
</div>
</template>
<template #summary>
{{ organization.description }}
</template>
<template #stats>
<div
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<UsersIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(acceptedMembers?.length || 0) }}
members
</div>
<div
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<BoxIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(projects?.length || 0) }}
projects
</div>
<div
v-tooltip="formatNumber(sumDownloads)"
class="flex items-center gap-2 font-semibold"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(sumDownloads) }}
downloads
</div>
</template>
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large">
<NuxtLink :to="`/organization/${organization.slug}/settings`">
<SettingsIcon aria-hidden="true" />
Manage
</NuxtLink>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:options="[
{
id: 'manage-projects',
action: () =>
router.push('/organization/' + organization?.slug + '/settings/projects'),
hoverFilledOnly: true,
shown: !!(auth.user && currentMember),
},
{ divider: true, shown: !!(auth?.user && currentMember) },
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #manage-projects>
<BoxIcon aria-hidden="true" />
Manage projects
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentPageHeader>
<PageHeader
:header="organization.name"
:summary="organization.description"
:leading="organizationHeaderLeading"
:badges="organizationHeaderBadges"
:metadata="organizationHeaderMetadata"
:actions="organizationHeaderActions"
/>
</div>
<div class="normal-page__sidebar">
<AdPlaceholder v-if="!auth.user" />
@@ -307,10 +235,9 @@ import {
Avatar,
ButtonStyled,
commonMessages,
ContentPageHeader,
injectModrinthClient,
NavTabs,
OverflowMenu,
PageHeader,
PROJECT_DEP_MARKER_QUERY,
ProjectCard,
ProjectCardList,
@@ -541,6 +468,93 @@ provideOrganizationContext(organizationContext)
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
const organizationHeaderLeading = computed(() => ({
type: 'avatar' as const,
src: organization.value?.icon_url,
alt: organization.value?.name,
avatarSize: '96px',
}))
const organizationHeaderBadges = computed(() => [
{
id: 'organization',
label: 'Organization',
icon: OrganizationIcon,
class: 'px-0 text-primary',
},
])
const organizationHeaderMetadata = computed(() => [
{
id: 'members',
label: `${formatCompactNumber(acceptedMembers.value?.length || 0)} members`,
icon: UsersIcon,
},
{
id: 'projects',
label: `${formatCompactNumber(projects.value?.length || 0)} projects`,
icon: BoxIcon,
},
{
id: 'downloads',
label: `${formatCompactNumber(sumDownloads.value)} downloads`,
icon: DownloadIcon,
tooltip: formatNumber(sumDownloads.value),
},
])
const organizationHeaderActions = computed(() => [
...(auth.value.user && currentMember.value
? [
{
id: 'manage',
label: 'Manage',
icon: SettingsIcon,
to: `/organization/${organization.value?.slug}/settings`,
},
]
: []),
{
id: 'more',
label: 'More options',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent' as const,
tooltip: 'More options',
menuActions: [
{
id: 'manage-projects',
label: 'Manage projects',
icon: BoxIcon,
action: () => {
void router.push(`/organization/${organization.value?.slug}/settings/projects`)
},
shown: !!(auth.value.user && currentMember.value),
},
{
divider: true,
shown: !!(auth.value.user && currentMember.value),
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => {
void copyId()
},
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => {
void copyPermalink()
},
},
],
},
])
watch(
[routeHasSettings, acceptedMembers, currentMember],
() => {
+192 -187
View File
@@ -120,35 +120,14 @@
</NewModal>
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
<div class="normal-page__header py-4">
<ContentPageHeader>
<template #icon>
<Avatar
:src="user.avatar_url"
:alt="user.username"
:size="isModrinthUser ? '64px' : '96px'"
circle
/>
</template>
<template #title>
<span class="flex items-center gap-2">
{{ user.username }}
<BadgeCheckIcon
v-if="isOfficialAccount"
v-tooltip="formatMessage(messages.officialAccount)"
class="size-5 text-brand"
fill="var(--color-brand-highlight)"
/>
<TagItem
v-if="isAdminViewing && isAffiliate"
:style="{
'--_color': 'var(--color-brand)',
'--_bg-color': 'var(--color-brand-highlight)',
}"
>
<AffiliateIcon /> Affiliate
</TagItem>
</span>
</template>
<PageHeader
:header="user.username"
:summary="isModrinthUser ? null : profileHeaderSummary"
:leading="profileHeaderLeading"
:badges="profileHeaderBadges"
:metadata="profileHeaderMetadata"
:actions="profileHeaderActions"
>
<template v-if="isModrinthUser" #summary>
<IntlFormatted :message-id="messages.officialAccountBio">
<template #support-link>
@@ -173,159 +152,7 @@
</template>
</IntlFormatted>
</template>
<template v-else #summary>
{{
user.bio
? user.bio
: projects?.length > 0
? formatMessage(messages.bioFallbackCreator)
: formatMessage(messages.bioFallbackUser)
}}
</template>
<template v-if="!isModrinthUser" #stats>
<div
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<BoxIcon class="h-6 w-6 text-secondary" />
{{
formatMessage(messages.profileProjectsLabel, {
count: formatCompactNumber(projects?.length || 0),
countPlural: formatCompactNumberPlural(projects?.length || 0),
})
}}
</div>
<div
v-tooltip="formatNumber(sumDownloads)"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{
formatMessage(messages.profileDownloadsLabel, {
count: formatCompactNumber(sumDownloads),
countPlural: formatCompactNumberPlural(sumDownloads),
})
}}
</div>
<div
v-tooltip="formatDateTime(user.created)"
class="flex items-center gap-2 font-semibold"
>
<CalendarIcon class="h-6 w-6 text-secondary" />
{{ formatMessage(messages.profileJoinedLabel) }}
{{ formatRelativeTime(user.created) }}
</div>
</template>
<template #actions>
<ButtonStyled size="large">
<NuxtLink v-if="auth.user && auth.user.id === user.id" to="/settings/profile">
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:options="[
{
id: 'manage-projects',
action: () => navigateTo('/dashboard/projects'),
hoverOnly: true,
shown: auth.user && auth.user.id === user.id,
},
{ divider: true, shown: auth.user && auth.user.id === user.id },
{
id: 'report',
action: () =>
auth.user ? reportUser(user.id) : navigateTo(getSignInRouteObj(route)),
color: 'red',
hoverOnly: true,
shown: auth.user?.id !== user.id,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
{
divider: true,
shown: auth.user && isAdmin(auth.user),
},
{
id: 'open-billing',
action: () => navigateTo(`/admin/billing/${user.id}`),
shown: auth.user && isStaff(auth.user),
},
{
id: 'toggle-affiliate',
action: () => toggleAffiliate(user.id),
shown: isAdminViewing,
remainOnClick: true,
color: isAffiliate ? 'red' : 'orange',
},
{
id: 'open-info',
action: () => $refs.userDetailsModal.show(),
shown: auth.user && isStaff(auth.user),
},
{
id: 'open-analytics',
action: () =>
navigateTo({
path: '/dashboard/analytics',
query: { user: user.username || user.id },
}),
shown: auth.user && isAdmin(auth.user),
},
{
id: 'edit-role',
action: () => openRoleEditModal(),
shown: auth.user && isAdmin(auth.user),
},
]"
aria-label="More options"
:dropdown-id="`${baseId}-more-options`"
>
<MoreVerticalIcon aria-hidden="true" />
<template #manage-projects>
<BoxIcon aria-hidden="true" />
{{ formatMessage(messages.profileManageProjectsButton) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
<template #open-billing>
<CurrencyIcon aria-hidden="true" />
{{ formatMessage(messages.billingButton) }}
</template>
<template #open-info>
<InfoIcon aria-hidden="true" />
{{ formatMessage(messages.infoButton) }}
</template>
<template #open-analytics>
<ChartIcon aria-hidden="true" />
{{ formatMessage(messages.analyticsButton) }}
</template>
<template #toggle-affiliate>
<AffiliateIcon aria-hidden="true" />
{{
formatMessage(
isAffiliate ? messages.removeAffiliateButton : messages.setAffiliateButton,
)
}}
</template>
<template #edit-role>
<EditIcon aria-hidden="true" />
{{ formatMessage(messages.editRoleButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentPageHeader>
</PageHeader>
</div>
<div class="normal-page__content">
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
@@ -535,17 +362,15 @@ import {
ButtonStyled,
Combobox,
commonMessages,
ContentPageHeader,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NavTabs,
NewModal,
OverflowMenu,
PageHeader,
ProjectCard,
ProjectCardList,
TagItem,
useCompactNumber,
useFormatDateTime,
useFormatNumber,
@@ -585,8 +410,6 @@ const formatDateTime = useFormatDateTime({
const { addNotification } = injectNotificationManager()
const baseId = useId()
const messages = defineMessages({
profileProjectsLabel: {
id: 'profile.label.projects',
@@ -893,12 +716,194 @@ async function copyPermalink() {
const isAffiliate = computed(() => user.value?.badges & UserBadge.AFFILIATE)
const isAdminViewing = computed(() => isAdmin(auth.value.user))
const userDetailsModal = useTemplateRef('userDetailsModal')
async function toggleAffiliate(id) {
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
queryClient.invalidateQueries({ queryKey: ['user', userId] })
}
const profileHeaderSummary = computed(() =>
user.value?.bio
? user.value.bio
: (projects.value?.length ?? 0) === 0
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator),
)
const profileHeaderLeading = computed(() => ({
type: 'avatar',
src: user.value?.avatar_url,
alt: user.value?.username,
avatarSize: isModrinthUser.value ? '64px' : '96px',
circle: true,
}))
const profileHeaderBadges = computed(() => [
...(isOfficialAccount.value
? [
{
id: 'official',
label: formatMessage(messages.officialAccount),
icon: BadgeCheckIcon,
iconProps: {
fill: 'var(--color-brand-highlight)',
},
tooltip: formatMessage(messages.officialAccount),
class: 'border-brand-highlight bg-brand-highlight text-brand',
},
]
: []),
...(isAdminViewing.value && isAffiliate.value
? [
{
id: 'affiliate',
label: 'Affiliate',
icon: AffiliateIcon,
class: 'border-brand-highlight bg-brand-highlight text-brand',
},
]
: []),
])
const profileHeaderMetadata = computed(() => {
if (isModrinthUser.value) return []
return [
{
id: 'projects',
label: formatMessage(messages.profileProjectsLabel, {
count: formatCompactNumber(projects.value?.length || 0),
countPlural: formatCompactNumberPlural(projects.value?.length || 0),
}),
icon: BoxIcon,
},
{
id: 'downloads',
label: formatMessage(messages.profileDownloadsLabel, {
count: formatCompactNumber(sumDownloads.value),
countPlural: formatCompactNumberPlural(sumDownloads.value),
}),
icon: DownloadIcon,
tooltip: formatNumber(sumDownloads.value),
},
{
id: 'joined',
label: `${formatMessage(messages.profileJoinedLabel)} ${formatRelativeTime(user.value.created)}`,
icon: CalendarIcon,
tooltip: formatDateTime(user.value.created),
},
]
})
const profileHeaderActions = computed(() => {
if (!user.value) return []
const viewer = auth.value.user
const isSelf = viewer?.id === user.value.id
return [
...(isSelf
? [
{
id: 'edit-profile',
label: formatMessage(commonMessages.editButton),
icon: EditIcon,
to: '/settings/profile',
},
]
: []),
{
id: 'more',
label: 'More options',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent',
tooltip: 'More options',
menuActions: [
{
id: 'manage-projects',
label: formatMessage(messages.profileManageProjectsButton),
icon: BoxIcon,
action: () => navigateTo('/dashboard/projects'),
shown: isSelf,
},
{
divider: true,
shown: isSelf,
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () => (viewer ? reportUser(user.value.id) : navigateTo(getSignInRouteObj(route))),
color: 'red',
shown: viewer?.id !== user.value.id,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => copyId(),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => copyPermalink(),
},
{
divider: true,
shown: viewer && isAdmin(viewer),
},
{
id: 'open-billing',
label: formatMessage(messages.billingButton),
icon: CurrencyIcon,
action: () => navigateTo(`/admin/billing/${user.value.id}`),
shown: viewer && isStaff(viewer),
},
{
id: 'toggle-affiliate',
label: formatMessage(
isAffiliate.value ? messages.removeAffiliateButton : messages.setAffiliateButton,
),
icon: AffiliateIcon,
action: () => toggleAffiliate(user.value.id),
shown: isAdminViewing.value,
remainOnClick: true,
color: isAffiliate.value ? 'red' : 'orange',
},
{
id: 'open-info',
label: formatMessage(messages.infoButton),
icon: InfoIcon,
action: () => userDetailsModal.value?.show(),
shown: viewer && isStaff(viewer),
},
{
id: 'open-analytics',
label: formatMessage(messages.analyticsButton),
icon: ChartIcon,
action: () =>
navigateTo({
path: '/dashboard/analytics',
query: { user: user.value.username || user.value.id },
}),
shown: viewer && isAdmin(viewer),
},
{
id: 'edit-role',
label: formatMessage(messages.editRoleButton),
icon: EditIcon,
action: () => openRoleEditModal(),
shown: viewer && isAdmin(viewer),
},
],
},
]
})
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),