You've already forked AstralRinth
forked from didirus/AstralRinth
543 lines
14 KiB
Vue
543 lines
14 KiB
Vue
<template>
|
|
<div v-if="organization" class="normal-page">
|
|
<div class="normal-page__sidebar">
|
|
<div v-if="routeHasSettings" 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>
|
|
|
|
<template v-else>
|
|
<div class="universal-card">
|
|
<div class="page-header__icon">
|
|
<Avatar size="md" :src="organization.icon_url" />
|
|
</div>
|
|
|
|
<div class="page-header__text">
|
|
<h1 class="title">{{ organization.name }}</h1>
|
|
|
|
<div>
|
|
<span class="organization-label"><OrganizationIcon /> Organization</span>
|
|
</div>
|
|
|
|
<div class="organization-description">
|
|
<div class="metadata-item markdown-body collection-description">
|
|
<p>{{ organization.description }}</p>
|
|
</div>
|
|
|
|
<hr class="card-divider" />
|
|
|
|
<div class="primary-stat">
|
|
<UserIcon class="primary-stat__icon" aria-hidden="true" />
|
|
<div class="primary-stat__text">
|
|
<span class="primary-stat__counter">
|
|
{{ $formatNumber(acceptedMembers?.length || 0) }}
|
|
</span>
|
|
member<template v-if="acceptedMembers?.length !== 1">s</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="primary-stat no-margin">
|
|
<BoxIcon class="primary-stat__icon" aria-hidden="true" />
|
|
<div class="primary-stat__text">
|
|
<span class="primary-stat__counter">
|
|
{{ $formatNumber(projects?.length || 0) }}
|
|
</span>
|
|
project<span v-if="organization.projects?.length !== 1">s</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="creator-list universal-card">
|
|
<div class="title-and-link">
|
|
<h3>Members</h3>
|
|
</div>
|
|
|
|
<template v-for="member in acceptedMembers" :key="member.user.id">
|
|
<nuxt-link class="creator button-base" :to="`/user/${member.user.username}`">
|
|
<Avatar :src="member.user.avatar_url" circle />
|
|
<p class="name">
|
|
{{ member.user.username }}
|
|
<CrownIcon v-if="member.is_owner" v-tooltip="'Organization owner'" />
|
|
</p>
|
|
<p class="role">{{ member.role }}</p>
|
|
</nuxt-link>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div v-if="!routeHasSettings" class="normal-page__content">
|
|
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
|
|
<Promotion />
|
|
<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>
|
|
<nav class="navigation-card">
|
|
<NavRow
|
|
:links="[
|
|
{
|
|
label: formatMessage(commonMessages.allProjectType),
|
|
href: `/organization/${organization.slug}`,
|
|
},
|
|
...projectTypes.map((x) => {
|
|
return {
|
|
label: formatMessage(getProjectTypeMessage(x, true)),
|
|
href: `/organization/${organization.slug}/${x}s`,
|
|
}
|
|
}),
|
|
]"
|
|
/>
|
|
|
|
<div v-if="auth.user && currentMember" class="input-group">
|
|
<nuxt-link :to="`/organization/${organization.slug}/settings`" class="iconified-button">
|
|
<SettingsIcon /> Manage
|
|
</nuxt-link>
|
|
</div>
|
|
</nav>
|
|
<template v-if="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(
|
|
route.params.projectType.substr(0, route.params.projectType.length - 1)
|
|
)
|
|
)
|
|
: projects"
|
|
: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"
|
|
:status="
|
|
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
|
|
? project.status
|
|
: null
|
|
"
|
|
:type="project.project_type"
|
|
: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?.organization_permissions, 1 << 4)">
|
|
Would you like to
|
|
<a class="link" @click="$refs.modal_creation.show()">create one</a>?
|
|
</template>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<NuxtPage />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import {
|
|
Avatar,
|
|
BoxIcon,
|
|
Breadcrumbs,
|
|
UserIcon,
|
|
UsersIcon,
|
|
SettingsIcon,
|
|
ChartIcon,
|
|
Promotion,
|
|
CheckIcon,
|
|
XIcon,
|
|
} from 'omorphia'
|
|
import NavStack from '~/components/ui/NavStack.vue'
|
|
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
|
import NavRow from '~/components/ui/NavRow.vue'
|
|
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
|
import UpToDate from '~/assets/images/illustrations/up_to_date.svg'
|
|
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
|
|
|
import OrganizationIcon from '~/assets/images/utils/organization.svg'
|
|
import CrownIcon from '~/assets/images/utils/crown.svg'
|
|
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
|
|
|
const vintl = useVIntl()
|
|
const { formatMessage } = vintl
|
|
|
|
const auth = await useAuth()
|
|
const user = await useUser()
|
|
const cosmetics = useCosmetics()
|
|
const route = useRoute()
|
|
const tags = useTags()
|
|
|
|
let 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 })
|
|
),
|
|
useAsyncData(`organization/${orgId}/projects`, () =>
|
|
useBaseFetch(`organization/${orgId}/projects`, { apiVersion: 3 })
|
|
),
|
|
])
|
|
|
|
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 currentMember = computed(() => {
|
|
if (auth.value.user && organization.value) {
|
|
const member = organization.value.members.find((x) => x.user.id === auth.value.user.id)
|
|
|
|
if (member) {
|
|
return member
|
|
}
|
|
|
|
if (tags.value.staffRoles.includes(auth.value.user.role)) {
|
|
return {
|
|
user: auth.value.user,
|
|
role: auth.value.user.role,
|
|
permissions: auth.value.user.role === 'admin' ? 1023 : 12,
|
|
accepted: true,
|
|
payouts_split: 0,
|
|
avatar_url: auth.value.user.avatar_url,
|
|
name: auth.value.user.username,
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
})
|
|
|
|
const hasPermission = computed(() => {
|
|
const EDIT_DETAILS = 1 << 2
|
|
return currentMember.value && (currentMember.value.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
|
})
|
|
|
|
const isInvited = computed(() => {
|
|
return currentMember.value?.accepted === false
|
|
})
|
|
|
|
const projectTypes = computed(() => {
|
|
const obj = {}
|
|
|
|
for (const project of projects.value) {
|
|
obj[project.project_types[0] ?? 'project'] = true
|
|
}
|
|
|
|
delete obj.project
|
|
|
|
return Object.keys(obj)
|
|
})
|
|
|
|
const patchIcon = async (icon) => {
|
|
const ext = icon.name.split('.').pop()
|
|
await useBaseFetch(`organization/${organization.value.id}/icon`, {
|
|
method: 'PATCH',
|
|
body: icon,
|
|
query: { ext },
|
|
apiVersion: 3,
|
|
})
|
|
}
|
|
|
|
const deleteIcon = async () => {
|
|
await useBaseFetch(`organization/${organization.value.id}/icon`, {
|
|
method: 'DELETE',
|
|
apiVersion: 3,
|
|
})
|
|
}
|
|
|
|
const patchOrganization = async (id, newData) => {
|
|
await useBaseFetch(`organization/${id}`, {
|
|
method: 'PATCH',
|
|
body: newData,
|
|
apiVersion: 3,
|
|
})
|
|
|
|
if (newData.slug) {
|
|
orgId = newData.slug
|
|
}
|
|
}
|
|
|
|
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()
|
|
})
|
|
|
|
provide('organizationContext', {
|
|
organization,
|
|
projects,
|
|
refresh,
|
|
currentMember,
|
|
hasPermission,
|
|
patchIcon,
|
|
deleteIcon,
|
|
patchOrganization,
|
|
})
|
|
|
|
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',
|
|
})
|
|
</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>
|