1
0
Files
AstralRinth/apps/frontend/src/pages/organization/[id].vue
Cal H. 2aabcf36ee refactor: migrate to common eslint+prettier configs (#4168)
* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
2025-08-14 20:48:38 +00:00

633 lines
16 KiB
Vue

<template>
<div
v-if="organization"
class="experimental-styles-within new-page sidebar"
:class="{ 'alt-layout': cosmetics.leftContentLayout || routeHasSettings }"
>
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
<template v-if="routeHasSettings">
<div class="normal-page__sidebar">
<div class="universal-card">
<Breadcrumbs
current-title="Settings"
:link-stack="[
{ href: `/dashboard/organizations`, label: 'Organizations' },
{
href: `/organization/${organization.slug}`,
label: organization.name,
allowTrimming: true,
},
]"
/>
<div class="page-header__settings">
<Avatar size="sm" :src="organization.icon_url" />
<div class="title-section">
<h2 class="settings-title">
<nuxt-link :to="`/organization/${organization.slug}/settings`">
{{ organization.name }}
</nuxt-link>
</h2>
<span>
{{ formatNumber(acceptedMembers?.length || 0) }}
member<template v-if="acceptedMembers?.length !== 1">s</template>
</span>
</div>
</div>
<h2>Organization settings</h2>
<NavStack>
<NavStackItem :link="`/organization/${organization.slug}/settings`" label="Overview">
<SettingsIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/members`"
label="Members"
>
<UsersIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/projects`"
label="Projects"
>
<BoxIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/analytics`"
label="Analytics"
>
<ChartIcon />
</NavStackItem>
</NavStack>
</div>
</div>
<div class="normal-page__content">
<NuxtPage />
</div>
</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="sumDownloads.toLocaleString()"
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>
</div>
<div class="normal-page__sidebar">
<AdPlaceholder v-if="!auth.user" />
<div class="card flex-card">
<h2>Members</h2>
<div class="details-list">
<template v-for="member in acceptedMembers" :key="member.user.id">
<nuxt-link
class="details-list__item details-list__item--type-large"
:to="`/user/${member?.user?.username}`"
>
<Avatar :src="member?.user.avatar_url" circle />
<div class="rows">
<span class="flex items-center gap-1">
{{ member?.user?.username }}
<CrownIcon
v-if="member?.is_owner"
v-tooltip="'Organization owner'"
class="text-brand-orange"
/>
</span>
<span class="details-list__item__text--style-secondary">
{{ member?.role ? member.role : 'Member' }}
</span>
</div>
</nuxt-link>
</template>
</div>
</div>
</div>
<div class="normal-page__content">
<div v-if="isInvited" class="universal-card information invited">
<h2>Invitation to join {{ organization.name }}</h2>
<p>You have been invited to join {{ organization.name }}.</p>
<div class="input-group">
<button class="iconified-button brand-button" @click="onAcceptInvite">
<CheckIcon />
Accept
</button>
<button class="iconified-button danger-button" @click="onDeclineInvite">
<XIcon />
Decline
</button>
</div>
</div>
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
<NavTabs :links="navLinks" />
</div>
<template v-if="projects && projects.length > 0">
<div class="project-list display-mode--list">
<ProjectCard
v-for="project in (route.params.projectType !== undefined
? (projects ?? []).filter((x) =>
x.project_types.includes(
typeof route.params.projectType === 'string'
? route.params.projectType.slice(0, route.params.projectType.length - 1)
: route.params.projectType[0]?.slice(
0,
route.params.projectType[0].length - 1,
) || '',
),
)
: (projects ?? [])
)
.slice()
.sort((a, b) => b.downloads - a.downloads)"
:id="project.slug || project.id"
:key="project.id"
:name="project.name"
:display="cosmetics.searchDisplayMode.user"
:featured-image="project.gallery.find((element) => element.featured)?.url"
project-type-url="project"
:description="project.summary"
:created-at="project.published"
:updated-at="project.updated"
:downloads="project.downloads.toString()"
:follows="project.followers.toString()"
:icon-url="project.icon_url"
:categories="project.categories"
:client-side="project.client_side"
:server-side="project.server_side"
:status="
auth.user &&
(auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
? (project.status as ProjectStatus)
: undefined
"
:type="project.project_types[0] ?? 'project'"
:color="project.color"
/>
</div>
</template>
<div v-else-if="true" class="error">
<UpToDate class="icon" />
<br />
<span class="preserve-lines text">
This organization doesn't have any projects yet.
<template v-if="isPermission(currentMember?.permissions, 1 << 4)">
Would you like to
<a class="link" @click="($refs as any).modal_creation?.show()">create one</a>?
</template>
</span>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import {
BoxIcon,
ChartIcon,
CheckIcon,
ClipboardCopyIcon,
CrownIcon,
DownloadIcon,
MoreVerticalIcon,
OrganizationIcon,
SettingsIcon,
UsersIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Breadcrumbs,
ButtonStyled,
commonMessages,
ContentPageHeader,
OverflowMenu,
} from '@modrinth/ui'
import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from '@modrinth/utils'
import { formatNumber } from '@modrinth/utils'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
import {
OrganizationContext,
provideOrganizationContext,
} from '~/providers/organization-context.ts'
import { isPermission } from '~/utils/permissions.ts'
const vintl = useVIntl()
const { formatMessage } = vintl
const formatCompactNumber = useCompactNumber(true)
const auth: { user: any } & any = await useAuth()
const user = await useUser()
const cosmetics = useCosmetics()
const route = useNativeRoute()
const router = useRouter()
const tags = useTags()
const config = useRuntimeConfig()
const orgId = useRouteId()
// hacky way to show the edit button on the corner of the card.
const routeHasSettings = computed(() => route.path.includes('settings'))
const [
{ data: organization, refresh: refreshOrganization },
{ data: projects, refresh: refreshProjects },
] = await Promise.all([
useAsyncData(
`organization/${orgId}`,
() => useBaseFetch(`organization/${orgId}`, { apiVersion: 3 }) as Promise<Organization>,
),
useAsyncData(
`organization/${orgId}/projects`,
() => useBaseFetch(`organization/${orgId}/projects`, { apiVersion: 3 }) as Promise<ProjectV3[]>,
{
transform: (projects) => {
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
if (project.mrpack_loaders) {
project.categories = project.categories.concat(project.mrpack_loaders)
}
const singleplayer = project.singleplayer && project.singleplayer[0]
const clientAndServer = project.client_and_server && project.client_and_server[0]
const clientOnly = project.client_only && project.client_only[0]
const serverOnly = project.server_only && project.server_only[0]
// quick and dirty hack to show envs as legacy
if (singleplayer && clientAndServer && !clientOnly && !serverOnly) {
project.client_side = 'required'
project.server_side = 'required'
} else if (singleplayer && clientAndServer && clientOnly && !serverOnly) {
project.client_side = 'required'
project.server_side = 'unsupported'
} else if (singleplayer && clientAndServer && !clientOnly && serverOnly) {
project.client_side = 'unsupported'
project.server_side = 'required'
} else if (singleplayer && clientAndServer && clientOnly && serverOnly) {
project.client_side = 'optional'
project.server_side = 'optional'
}
}
return projects
},
},
),
])
const refresh = async () => {
await Promise.all([refreshOrganization(), refreshProjects()])
}
if (!organization.value) {
throw createError({
fatal: true,
statusCode: 404,
message: 'Organization not found',
})
}
// Filter accepted, sort by role, then by name and Owner role always goes first
const acceptedMembers = computed(() => {
const acceptedMembers = organization.value?.members?.filter((x) => x.accepted) ?? []
const owner = acceptedMembers.find((x) => x.is_owner)
const rest = acceptedMembers.filter((x) => !x.is_owner) || []
rest.sort((a, b) => {
if (a.role === b.role) {
return a.user.username.localeCompare(b.user.username)
} else {
return a.role.localeCompare(b.role)
}
})
return [owner, ...rest]
})
const isInvited = computed(() => {
return currentMember.value?.accepted === false
})
const projectTypes = computed(() => {
const obj: Record<string, boolean> = {}
for (const project of projects.value ?? []) {
obj[project.project_types[0] ?? 'project'] = true
}
delete obj.project
return Object.keys(obj)
})
const sumDownloads = computed(() => {
let sum = 0
for (const project of projects.value ?? []) {
sum += project.downloads
}
return sum
})
const onAcceptInvite = useClientTry(async () => {
await acceptTeamInvite(organization.value?.team_id)
await refreshOrganization()
})
const onDeclineInvite = useClientTry(async () => {
await removeTeamMember(organization.value?.team_id, auth.value?.user?.id)
await refreshOrganization()
})
const organizationContext = new OrganizationContext(organization, projects, auth, tags, refresh)
const { currentMember } = organizationContext
provideOrganizationContext(organizationContext)
const title = `${organization.value.name} - Organization`
const description = `${organization.value.description} - View the organization ${organization.value.name} on Modrinth`
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: organization.value.description,
ogImage: organization.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
})
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),
href: `/organization/${organization.value?.slug}`,
},
...projectTypes.value
.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x as ProjectType, true)),
href: `/organization/${organization.value?.slug}/${x}s`,
}
})
.slice()
.sort((a, b) => a.label.localeCompare(b.label)),
])
async function copyId() {
await navigator.clipboard.writeText(organization.value?.id ?? '')
}
async function copyPermalink() {
await navigator.clipboard.writeText(
`${config.public.siteUrl}/organization/${organization.value?.id}`,
)
}
</script>
<style scoped lang="scss">
.page-header__settings {
display: flex;
flex-direction: row;
gap: var(--gap-md);
margin-bottom: var(--gap-md);
.title-section {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--gap-xs);
}
.settings-title {
margin: 0 !important;
font-size: var(--font-size-md);
}
}
.page-header__icon {
margin-block: 0 !important;
}
.universal-card {
h1 {
margin-bottom: var(--gap-md);
}
}
.creator-list {
display: flex;
flex-direction: column;
padding: var(--gap-xl);
h3 {
margin: 0 0 var(--gap-sm);
}
.creator {
display: grid;
gap: var(--gap-xs);
background-color: var(--color-raised-bg);
padding: var(--gap-sm);
margin-left: -0.5rem;
border-radius: var(--radius-lg);
grid-template:
'avatar name' auto
'avatar role' auto
/ auto 1fr;
p {
margin: 0;
}
.name {
grid-area: name;
align-self: flex-end;
margin-left: var(--gap-xs);
font-weight: bold;
display: flex;
align-items: center;
gap: 0.25rem;
svg {
color: var(--color-orange);
}
}
.role {
grid-area: role;
align-self: flex-start;
margin-left: var(--gap-xs);
}
.avatar {
grid-area: avatar;
}
}
}
.secondary-stat {
align-items: center;
display: flex;
margin-bottom: 0.8rem;
}
.secondary-stat__icon {
height: 1rem;
width: 1rem;
}
.secondary-stat__text {
margin-left: 0.4rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.title {
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
font-size: var(--font-size-xl);
color: var(--color-text-dark);
}
.organization-label {
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
}
.organization-description {
margin-top: var(--spacing-card-sm);
margin-bottom: 0;
}
.title-and-link {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
}
a {
display: flex;
align-items: center;
gap: var(--gap-xs);
color: var(--color-blue);
}
}
.project-overview {
gap: var(--gap-md);
padding: var(--gap-xl);
.project-card {
padding: 0;
border-radius: 0;
background-color: transparent;
box-shadow: none;
:deep(.title) {
font-size: var(--font-size-nm) !important;
}
}
}
.popout-heading {
padding: var(--gap-sm) var(--gap-md);
margin: 0;
font-size: var(--font-size-md);
color: var(--color-text);
}
.popout-checkbox {
padding: var(--gap-sm) var(--gap-md);
}
</style>