You've already forked AstralRinth
forked from didirus/AstralRinth
New organizations (#1488)
* [WIP] Transfer organizations to own branch * push progress * Setup organizations page * Add organizations grid to user profile * Remove debug * Add error handling for failed organization fetch * Refactor organization page and settings * Restructure to composition setup api * checklist completion * Apply suggestions from code review Co-authored-by: Emma Alexia <emma@modrinth.com> * Update pages/[type]/[id]/settings/index.vue Co-authored-by: Emma Alexia <emma@modrinth.com> * Update pages/[type]/[id]/settings/index.vue Co-authored-by: Emma Alexia <emma@modrinth.com> * Update pages/[type]/[id]/settings/index.vue Co-authored-by: Emma Alexia <emma@modrinth.com> * Update pages/[type]/[id]/settings/index.vue Co-authored-by: Emma Alexia <emma@modrinth.com> * Clean up org state management * Refactor useClientTry to simplify code * Remove unused code and update dependencies * Refactor bulkEditLinks event handler * Refactor organization management functions * Update heading from "Creators" to "Members" * Refactor team member invitation * Refactor member management functions * Implement validation on clientside for org names * Name sanitization for fun characters * Update onInviteTeamMember function parameters * Remove name * sidebar * random rendering issue * Conform to org removal * Org no projects conditional * Update organization links in dashboard * Update Cards to universal-cards * Refactor gallery upload permissions * Refactor to sidebar pattern * Update button classes in gallery and versions components * Finish (most) * almost finish * Finish orgs :D * Fix lint * orgs fixes * fix most things * project settings * convert grid to cards * clean up unused test class * Settings -> Manage * add org view to org management * Fix prop mounting issue * fix analytics grid layout overflow * fix multiselect breaking layout * Refactor chart selection logic in ChartDisplay.vue * Add transfer modal --------- Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Emma Alexia <emma@modrinth.com>
This commit is contained in:
1
assets/images/utils/organization.svg
Normal file
1
assets/images/utils/organization.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-building-2"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></svg>
|
||||||
|
After Width: | Height: | Size: 456 B |
@@ -1133,6 +1133,10 @@ button {
|
|||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.no-margin {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-list {
|
.project-list {
|
||||||
|
|||||||
@@ -4,14 +4,6 @@
|
|||||||
<div class="markdown-body">
|
<div class="markdown-body">
|
||||||
<p>New projects are created as drafts and can be found under your profile page.</p>
|
<p>New projects are created as drafts and can be found under your profile page.</p>
|
||||||
</div>
|
</div>
|
||||||
<label for="project-type">
|
|
||||||
<span class="label__title">Project type<span class="required">*</span></span>
|
|
||||||
</label>
|
|
||||||
<Chips
|
|
||||||
id="project-type"
|
|
||||||
v-model="projectType"
|
|
||||||
:items="tags.projectTypes.map((x) => x.display)"
|
|
||||||
/>
|
|
||||||
<label for="name">
|
<label for="name">
|
||||||
<span class="label__title">Name<span class="required">*</span></span>
|
<span class="label__title">Name<span class="required">*</span></span>
|
||||||
</label>
|
</label>
|
||||||
@@ -28,9 +20,7 @@
|
|||||||
<span class="label__title">URL<span class="required">*</span></span>
|
<span class="label__title">URL<span class="required">*</span></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="text-input-wrapper">
|
<div class="text-input-wrapper">
|
||||||
<div class="text-input-wrapper__before">
|
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
|
||||||
https://modrinth.com/{{ getProjectType() ? getProjectType().id : '???' }}/
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
id="slug"
|
id="slug"
|
||||||
v-model="slug"
|
v-model="slug"
|
||||||
@@ -40,6 +30,25 @@
|
|||||||
@input="manualSlug = true"
|
@input="manualSlug = true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label for="visibility">
|
||||||
|
<span class="label__title">Visibility<span class="required">*</span></span>
|
||||||
|
<span class="label__description">
|
||||||
|
The visibility of your project after it has been approved.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<multiselect
|
||||||
|
id="visibility"
|
||||||
|
v-model="visibility"
|
||||||
|
:options="visibilities"
|
||||||
|
track-by="actual"
|
||||||
|
label="display"
|
||||||
|
:multiple="false"
|
||||||
|
:searchable="false"
|
||||||
|
:show-no-results="false"
|
||||||
|
:show-labels="false"
|
||||||
|
placeholder="Choose visibility.."
|
||||||
|
open-direction="bottom"
|
||||||
|
/>
|
||||||
<label for="additional-information">
|
<label for="additional-information">
|
||||||
<span class="label__title">Summary<span class="required">*</span></span>
|
<span class="label__title">Summary<span class="required">*</span></span>
|
||||||
<span class="label__description"
|
<span class="label__description"
|
||||||
@@ -64,26 +73,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { Multiselect } from 'vue-multiselect'
|
||||||
import CrossIcon from '~/assets/images/utils/x.svg'
|
import CrossIcon from '~/assets/images/utils/x.svg'
|
||||||
import CheckIcon from '~/assets/images/utils/right-arrow.svg'
|
import CheckIcon from '~/assets/images/utils/right-arrow.svg'
|
||||||
import Modal from '~/components/ui/Modal.vue'
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
import Chips from '~/components/ui/Chips.vue'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Chips,
|
|
||||||
CrossIcon,
|
CrossIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
Modal,
|
Modal,
|
||||||
|
Multiselect,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
itemType: {
|
organizationId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
required: false,
|
||||||
},
|
default: null,
|
||||||
itemId: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
@@ -93,80 +99,68 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
projectType: this.tags.projectTypes[0].display,
|
|
||||||
name: '',
|
name: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
description: '',
|
description: '',
|
||||||
manualSlug: false,
|
manualSlug: false,
|
||||||
|
visibilities: [
|
||||||
|
{
|
||||||
|
actual: 'approved',
|
||||||
|
display: 'Public',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actual: 'private',
|
||||||
|
display: 'Private',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actual: 'unlisted',
|
||||||
|
display: 'Unlisted',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
visibility: {
|
||||||
|
actual: 'approved',
|
||||||
|
display: 'Public',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
cancel() {
|
cancel() {
|
||||||
this.$refs.modal.hide()
|
this.$refs.modal.hide()
|
||||||
},
|
},
|
||||||
getProjectType() {
|
|
||||||
return this.tags.projectTypes.find((x) => this.projectType === x.display)
|
|
||||||
},
|
|
||||||
getClientSide() {
|
|
||||||
switch (this.getProjectType().id) {
|
|
||||||
case 'plugin':
|
|
||||||
return 'unsupported'
|
|
||||||
case 'resourcepack':
|
|
||||||
return 'required'
|
|
||||||
case 'shader':
|
|
||||||
return 'required'
|
|
||||||
case 'datapack':
|
|
||||||
return 'optional'
|
|
||||||
default:
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getServerSide() {
|
|
||||||
switch (this.getProjectType().id) {
|
|
||||||
case 'plugin':
|
|
||||||
return 'required'
|
|
||||||
case 'resourcepack':
|
|
||||||
return 'unsupported'
|
|
||||||
case 'shader':
|
|
||||||
return 'unsupported'
|
|
||||||
case 'datapack':
|
|
||||||
return 'required'
|
|
||||||
default:
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async createProject() {
|
async createProject() {
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
const projectType = this.getProjectType()
|
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
|
||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
|
|
||||||
formData.append(
|
const projectData = {
|
||||||
'data',
|
title: this.name.trim(),
|
||||||
JSON.stringify({
|
project_type: 'mod',
|
||||||
title: this.name.trim(),
|
slug: this.slug,
|
||||||
project_type: projectType.actual,
|
description: this.description.trim(),
|
||||||
slug: this.slug,
|
body: '',
|
||||||
description: this.description.trim(),
|
requested_status: this.visibility.actual,
|
||||||
body: '',
|
initial_versions: [],
|
||||||
initial_versions: [],
|
team_members: [
|
||||||
team_members: [
|
{
|
||||||
{
|
user_id: auth.value.user.id,
|
||||||
user_id: auth.value.user.id,
|
name: auth.value.user.username,
|
||||||
name: auth.value.user.username,
|
role: 'Owner',
|
||||||
role: 'Owner',
|
},
|
||||||
},
|
],
|
||||||
],
|
categories: [],
|
||||||
categories: [],
|
client_side: 'required',
|
||||||
client_side: this.getClientSide(),
|
server_side: 'required',
|
||||||
server_side: this.getServerSide(),
|
license_id: 'LicenseRef-Unknown',
|
||||||
license_id: 'LicenseRef-Unknown',
|
is_draft: true,
|
||||||
is_draft: true,
|
}
|
||||||
})
|
|
||||||
)
|
if (this.organizationId) {
|
||||||
|
projectData.organization_id = this.organizationId
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append('data', JSON.stringify(projectData))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await useBaseFetch('project', {
|
await useBaseFetch('project', {
|
||||||
@@ -181,12 +175,9 @@ export default {
|
|||||||
await this.$router.push({
|
await this.$router.push({
|
||||||
name: 'type-id',
|
name: 'type-id',
|
||||||
params: {
|
params: {
|
||||||
type: projectType.id,
|
type: 'project',
|
||||||
id: this.slug,
|
id: this.slug,
|
||||||
},
|
},
|
||||||
state: {
|
|
||||||
overrideProjectType: projectType.id,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$notify({
|
this.$notify({
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
|
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
|
||||||
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
|
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
<nuxt-link
|
||||||
|
v-else-if="organization"
|
||||||
|
:to="`/organization/${organization.slug}`"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
|
||||||
|
</nuxt-link>
|
||||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
||||||
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -31,6 +38,10 @@
|
|||||||
class="moderation-color"
|
class="moderation-color"
|
||||||
/>
|
/>
|
||||||
<InvitationIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
|
<InvitationIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
|
||||||
|
<InvitationIcon
|
||||||
|
v-else-if="type === 'organization_invite' && organization"
|
||||||
|
class="creator-color"
|
||||||
|
/>
|
||||||
<VersionIcon v-else-if="type === 'project_update' && project && version" />
|
<VersionIcon v-else-if="type === 'project_update' && project && version" />
|
||||||
<NotificationIcon v-else />
|
<NotificationIcon v-else />
|
||||||
</template>
|
</template>
|
||||||
@@ -54,6 +65,19 @@
|
|||||||
>.
|
>.
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="type === 'organization_invite' && organization">
|
||||||
|
<nuxt-link :to="`/user/${invitedBy.username}`" class="iconified-link title-link">
|
||||||
|
<Avatar :src="invitedBy.avatar_url" circle size="xxs" no-shadow :raised="raised" />
|
||||||
|
<span class="space"> </span>
|
||||||
|
<span>{{ invitedBy.username }}</span>
|
||||||
|
</nuxt-link>
|
||||||
|
<span>
|
||||||
|
has invited you to join
|
||||||
|
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
|
||||||
|
{{ organization.name }} </nuxt-link
|
||||||
|
>.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<template v-else-if="type === 'status_change' && project">
|
<template v-else-if="type === 'status_change' && project">
|
||||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||||
{{ project.title }}
|
{{ project.title }}
|
||||||
@@ -154,7 +178,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div v-if="compact" class="notification__actions">
|
<div v-if="compact" class="notification__actions">
|
||||||
<template v-if="type === 'team_invite'">
|
<template v-if="type === 'team_invite' || type === 'organization_invite'">
|
||||||
<button
|
<button
|
||||||
v-tooltip="`Accept`"
|
v-tooltip="`Accept`"
|
||||||
class="iconified-button square-button brand-button button-transparent"
|
class="iconified-button square-button brand-button button-transparent"
|
||||||
@@ -191,7 +215,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="notification__actions">
|
<div v-else class="notification__actions">
|
||||||
<div v-if="type !== null" class="input-group">
|
<div v-if="type !== null" class="input-group">
|
||||||
<template v-if="type === 'team_invite' && !notification.read">
|
<template
|
||||||
|
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="iconified-button brand-button"
|
class="iconified-button brand-button"
|
||||||
@click="
|
@click="
|
||||||
@@ -322,6 +348,7 @@ const report = computed(() => props.notification.extra_data.report)
|
|||||||
const project = computed(() => props.notification.extra_data.project)
|
const project = computed(() => props.notification.extra_data.project)
|
||||||
const version = computed(() => props.notification.extra_data.version)
|
const version = computed(() => props.notification.extra_data.version)
|
||||||
const user = computed(() => props.notification.extra_data.user)
|
const user = computed(() => props.notification.extra_data.user)
|
||||||
|
const organization = computed(() => props.notification.extra_data.organization)
|
||||||
const invitedBy = computed(() => props.notification.extra_data.invited_by)
|
const invitedBy = computed(() => props.notification.extra_data.invited_by)
|
||||||
|
|
||||||
const threadLink = computed(() => {
|
const threadLink = computed(() => {
|
||||||
|
|||||||
138
components/ui/OrganizationCreateModal.vue
Normal file
138
components/ui/OrganizationCreateModal.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<Modal ref="modal" header="Create an organization">
|
||||||
|
<div class="universal-modal modal-creation universal-labels">
|
||||||
|
<div class="markdown-body">
|
||||||
|
<p>
|
||||||
|
Organizations can be found under your profile page. You will be set as its owner, but you
|
||||||
|
can invite other members and transfer ownership at any time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label for="name">
|
||||||
|
<span class="label__title">Name<span class="required">*</span></span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
maxlength="64"
|
||||||
|
:placeholder="`Enter organization name...`"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="updateSlug()"
|
||||||
|
/>
|
||||||
|
<label for="slug">
|
||||||
|
<span class="label__title">URL<span class="required">*</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="text-input-wrapper">
|
||||||
|
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
|
||||||
|
<input
|
||||||
|
id="slug"
|
||||||
|
v-model="slug"
|
||||||
|
type="text"
|
||||||
|
maxlength="64"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="manualSlug = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label for="additional-information">
|
||||||
|
<span class="label__title">Summary<span class="required">*</span></span>
|
||||||
|
<span class="label__description">This will appear on your organization's page.</span>
|
||||||
|
</label>
|
||||||
|
<div class="textarea-wrapper">
|
||||||
|
<textarea id="additional-information" v-model="description" maxlength="256" />
|
||||||
|
</div>
|
||||||
|
<div class="push-right input-group">
|
||||||
|
<Button @click="modal.hide()">
|
||||||
|
<CrossIcon />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" @click="createProject">
|
||||||
|
<CheckIcon />
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { XIcon as CrossIcon, CheckIcon, Modal, Button } from 'omorphia'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const slug = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const manualSlug = ref(false)
|
||||||
|
|
||||||
|
const modal = ref()
|
||||||
|
|
||||||
|
async function createProject() {
|
||||||
|
startLoading()
|
||||||
|
try {
|
||||||
|
const value = {
|
||||||
|
name: name.value.trim(),
|
||||||
|
description: description.value.trim(),
|
||||||
|
slug: slug.value.trim().replace(/ +/g, ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await useBaseFetch('organization', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(value),
|
||||||
|
apiVersion: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
modal.value.hide()
|
||||||
|
|
||||||
|
await router.push(`/organization/${result.slug}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'An error occurred',
|
||||||
|
text: err.data.description,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stopLoading()
|
||||||
|
}
|
||||||
|
function show() {
|
||||||
|
name.value = ''
|
||||||
|
description.value = ''
|
||||||
|
modal.value.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlug() {
|
||||||
|
if (!manualSlug.value) {
|
||||||
|
slug.value = name.value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(' ', '-')
|
||||||
|
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
|
||||||
|
.replaceAll(/--+/gm, '-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.modal-creation {
|
||||||
|
input {
|
||||||
|
width: 20rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-top: var(--gap-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
255
components/ui/OrganizationProjectTransferModal.vue
Normal file
255
components/ui/OrganizationProjectTransferModal.vue
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Modal ref="modalOpen" header="Transfer Projects">
|
||||||
|
<div class="universal-modal items">
|
||||||
|
<div class="table">
|
||||||
|
<div class="table-row table-head">
|
||||||
|
<div class="table-cell check-cell">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="selectedProjects.length === props.projects.length"
|
||||||
|
@update:model-value="toggleSelectedProjects()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="table-cell">Icon</div>
|
||||||
|
<div class="table-cell">Name</div>
|
||||||
|
<div class="table-cell">ID</div>
|
||||||
|
<div class="table-cell">Type</div>
|
||||||
|
<div class="table-cell" />
|
||||||
|
</div>
|
||||||
|
<div v-for="project in props.projects" :key="`project-${project.id}`" class="table-row">
|
||||||
|
<div class="table-cell check-cell">
|
||||||
|
<Checkbox
|
||||||
|
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
|
||||||
|
:model-value="selectedProjects.includes(project)"
|
||||||
|
@update:model-value="
|
||||||
|
selectedProjects.includes(project)
|
||||||
|
? (selectedProjects = selectedProjects.filter((it) => it !== project))
|
||||||
|
: selectedProjects.push(project)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="table-cell">
|
||||||
|
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
|
||||||
|
<Avatar
|
||||||
|
:src="project.icon_url"
|
||||||
|
aria-hidden="true"
|
||||||
|
:alt="'Icon for ' + project.name"
|
||||||
|
no-shadow
|
||||||
|
/>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<span class="project-title">
|
||||||
|
<nuxt-link
|
||||||
|
class="hover-link wrap-as-needed"
|
||||||
|
:to="`/project/${project.slug ? project.slug : project.id}`"
|
||||||
|
>
|
||||||
|
{{ project.title }}
|
||||||
|
</nuxt-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<CopyCode :text="project.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<BoxIcon />
|
||||||
|
<span>{{
|
||||||
|
$formatProjectType(
|
||||||
|
$getProjectTypeForDisplay(
|
||||||
|
project.project_types?.[0] ?? 'project',
|
||||||
|
project.loaders
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<nuxt-link
|
||||||
|
class="btn icon-only"
|
||||||
|
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="push-right input-group">
|
||||||
|
<Button @click="$refs.modalOpen?.hide()">
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="!selectedProjects?.length" color="primary" @click="onSubmitHandler()">
|
||||||
|
<TransferIcon />
|
||||||
|
<span>
|
||||||
|
Transfer
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
selectedProjects.length === props.projects.length
|
||||||
|
? 'All'
|
||||||
|
: selectedProjects.length
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ ' ' }}
|
||||||
|
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Button @click="$refs.modalOpen?.show()">
|
||||||
|
<TransferIcon />
|
||||||
|
<span>Transfer projects</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
CopyCode,
|
||||||
|
Avatar,
|
||||||
|
BoxIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
TransferIcon,
|
||||||
|
XIcon,
|
||||||
|
} from 'omorphia'
|
||||||
|
|
||||||
|
const modalOpen = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
projects: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// define emit for submission
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
const selectedProjects = ref([])
|
||||||
|
|
||||||
|
const toggleSelectedProjects = () => {
|
||||||
|
if (selectedProjects.value.length === props.projects.length) {
|
||||||
|
selectedProjects.value = []
|
||||||
|
} else {
|
||||||
|
selectedProjects.value = props.projects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmitHandler = () => {
|
||||||
|
if (selectedProjects.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('submit', selectedProjects.value)
|
||||||
|
selectedProjects.value = []
|
||||||
|
modalOpen.value?.hide()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.table {
|
||||||
|
display: grid;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: var(--gap-md);
|
||||||
|
border: 1px solid var(--color-button-bg);
|
||||||
|
background-color: var(--color-raised-bg);
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
padding: var(--gap-md);
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-cell {
|
||||||
|
padding-left: var(--gap-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 750px) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template: 'checkbox icon name type settings' 'checkbox icon id type settings';
|
||||||
|
grid-template-columns:
|
||||||
|
min-content min-content minmax(min-content, 2fr)
|
||||||
|
minmax(min-content, 1fr) min-content;
|
||||||
|
|
||||||
|
:nth-child(1) {
|
||||||
|
grid-area: checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(2) {
|
||||||
|
grid-area: icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(3) {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(4) {
|
||||||
|
grid-area: id;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(5) {
|
||||||
|
grid-area: type;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(6) {
|
||||||
|
grid-area: settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
grid-template: 'checkbox settings';
|
||||||
|
grid-template-columns: min-content minmax(min-content, 1fr);
|
||||||
|
|
||||||
|
:nth-child(2),
|
||||||
|
:nth-child(3),
|
||||||
|
:nth-child(4),
|
||||||
|
:nth-child(5) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 560px) {
|
||||||
|
.table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
|
||||||
|
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
|
||||||
|
|
||||||
|
:nth-child(5) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
grid-template: 'checkbox settings';
|
||||||
|
grid-template-columns: min-content minmax(min-content, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
padding: var(--gap-md);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
class="tags"
|
class="tags"
|
||||||
>
|
>
|
||||||
<EnvironmentIndicator
|
<EnvironmentIndicator
|
||||||
|
v-if="clientSide && serverSide"
|
||||||
:type-only="moderation"
|
:type-only="moderation"
|
||||||
:client-side="clientSide"
|
:client-side="clientSide"
|
||||||
:server-side="serverSide"
|
:server-side="serverSide"
|
||||||
|
|||||||
@@ -54,7 +54,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!collapsed" class="grid-display width-16">
|
<div v-if="!collapsed" class="grid-display width-16">
|
||||||
<div v-for="nag in nags.filter((x) => x.condition)" :key="nag.id" class="grid-display__item">
|
<div
|
||||||
|
v-for="nag in nags.filter((x) => x.condition && !x.hide)"
|
||||||
|
:key="nag.id"
|
||||||
|
class="grid-display__item"
|
||||||
|
>
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<RequiredIcon
|
<RequiredIcon
|
||||||
v-if="nag.status === 'required'"
|
v-if="nag.status === 'required'"
|
||||||
@@ -101,7 +105,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { formatProjectType } from '~/plugins/shorthands.js'
|
||||||
|
|
||||||
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
|
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
|
||||||
import DropdownIcon from '~/assets/images/utils/dropdown.svg'
|
import DropdownIcon from '~/assets/images/utils/dropdown.svg'
|
||||||
import CheckIcon from '~/assets/images/utils/check.svg'
|
import CheckIcon from '~/assets/images/utils/check.svg'
|
||||||
@@ -110,309 +116,266 @@ import RequiredIcon from '~/assets/images/utils/asterisk.svg'
|
|||||||
import SuggestionIcon from '~/assets/images/utils/lightbulb.svg'
|
import SuggestionIcon from '~/assets/images/utils/lightbulb.svg'
|
||||||
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
|
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
|
||||||
import SendIcon from '~/assets/images/utils/send.svg'
|
import SendIcon from '~/assets/images/utils/send.svg'
|
||||||
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams.js'
|
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
project: {
|
||||||
ChevronRightIcon,
|
type: Object,
|
||||||
DropdownIcon,
|
required: true,
|
||||||
CheckIcon,
|
|
||||||
CrossIcon,
|
|
||||||
RequiredIcon,
|
|
||||||
SuggestionIcon,
|
|
||||||
ModerationIcon,
|
|
||||||
SendIcon,
|
|
||||||
},
|
},
|
||||||
props: {
|
versions: {
|
||||||
project: {
|
type: Array,
|
||||||
type: Object,
|
default() {
|
||||||
required: true,
|
return []
|
||||||
},
|
|
||||||
versions: {
|
|
||||||
type: Array,
|
|
||||||
default() {
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
},
|
|
||||||
currentMember: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
allMembers: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
isSettings: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
collapsed: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
routeName: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
setProcessing: {
|
|
||||||
type: Function,
|
|
||||||
default() {
|
|
||||||
return () => {
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'An error occurred',
|
|
||||||
text: 'setProcessing function not found',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toggleCollapsed: {
|
|
||||||
type: Function,
|
|
||||||
default() {
|
|
||||||
return () => {
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'An error occurred',
|
|
||||||
text: 'toggleCollapsed function not found',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
updateMembers: {
|
|
||||||
type: Function,
|
|
||||||
default() {
|
|
||||||
return () => {
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'An error occurred',
|
|
||||||
text: 'updateMembers function not found',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
currentMember: {
|
||||||
featuredGalleryImage() {
|
type: Object,
|
||||||
return this.project.gallery.find((img) => img.featured)
|
default: null,
|
||||||
|
},
|
||||||
|
allMembers: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isSettings: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
collapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
routeName: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
setProcessing: {
|
||||||
|
type: Function,
|
||||||
|
default() {
|
||||||
|
return () => {
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'An error occurred',
|
||||||
|
text: 'setProcessing function not found',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
nags() {
|
},
|
||||||
return [
|
toggleCollapsed: {
|
||||||
{
|
type: Function,
|
||||||
condition:
|
default() {
|
||||||
this.project.body === '' || this.project.body.startsWith('# Placeholder description'),
|
return () => {
|
||||||
title: 'Add a description',
|
addNotification({
|
||||||
id: 'add-description',
|
group: 'main',
|
||||||
description:
|
title: 'An error occurred',
|
||||||
"A description that clearly describes the project's purpose and function is required.",
|
text: 'toggleCollapsed function not found',
|
||||||
status: 'required',
|
type: 'error',
|
||||||
link: {
|
})
|
||||||
path: 'settings/description',
|
}
|
||||||
title: 'Visit description settings',
|
},
|
||||||
hide: this.routeName === 'type-id-settings-description',
|
},
|
||||||
},
|
updateMembers: {
|
||||||
},
|
type: Function,
|
||||||
{
|
default() {
|
||||||
condition: !this.project.icon_url,
|
return () => {
|
||||||
title: 'Add an icon',
|
addNotification({
|
||||||
id: 'add-icon',
|
group: 'main',
|
||||||
description:
|
title: 'An error occurred',
|
||||||
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
|
text: 'updateMembers function not found',
|
||||||
status: 'suggestion',
|
type: 'error',
|
||||||
link: {
|
})
|
||||||
path: 'settings',
|
}
|
||||||
title: 'Visit general settings',
|
},
|
||||||
hide: this.routeName === 'type-id-settings',
|
},
|
||||||
},
|
})
|
||||||
},
|
|
||||||
{
|
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured))
|
||||||
condition: !this.featuredGalleryImage,
|
|
||||||
title: 'Feature a gallery image',
|
const nags = computed(() => [
|
||||||
id: 'feature-gallery-image',
|
{
|
||||||
description: 'Featured gallery images may be the first impression of many users.',
|
condition: props.versions.length < 1,
|
||||||
status: 'suggestion',
|
title: 'Upload a version',
|
||||||
link: {
|
id: 'upload-version',
|
||||||
path: 'gallery',
|
description: 'At least one version is required for a project to be submitted for review.',
|
||||||
title: 'Visit gallery page',
|
status: 'required',
|
||||||
hide: this.routeName === 'type-id-gallery',
|
link: {
|
||||||
},
|
path: 'versions',
|
||||||
},
|
title: 'Visit versions page',
|
||||||
{
|
hide: props.routeName === 'type-id-versions',
|
||||||
condition: this.versions.length < 1,
|
},
|
||||||
title: 'Upload a version',
|
},
|
||||||
id: 'upload-version',
|
{
|
||||||
description: 'At least one version is required for a project to be submitted for review.',
|
condition:
|
||||||
status: 'required',
|
props.project.body === '' || props.project.body.startsWith('# Placeholder description'),
|
||||||
link: {
|
title: 'Add a description',
|
||||||
path: 'versions',
|
id: 'add-description',
|
||||||
title: 'Visit versions page',
|
description:
|
||||||
hide: this.routeName === 'type-id-versions',
|
"A description that clearly describes the project's purpose and function is required.",
|
||||||
},
|
status: 'required',
|
||||||
},
|
link: {
|
||||||
{
|
path: 'settings/description',
|
||||||
condition: this.project.categories.length < 1,
|
title: 'Visit description settings',
|
||||||
title: 'Select tags',
|
hide: props.routeName === 'type-id-settings-description',
|
||||||
id: 'select-tags',
|
},
|
||||||
description: 'Select all tags that apply to your project.',
|
},
|
||||||
status: 'suggestion',
|
{
|
||||||
link: {
|
condition: !props.project.icon_url,
|
||||||
path: 'settings/tags',
|
title: 'Add an icon',
|
||||||
title: 'Visit tag settings',
|
id: 'add-icon',
|
||||||
hide: this.routeName === 'type-id-settings-tags',
|
description:
|
||||||
},
|
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
|
||||||
},
|
status: 'suggestion',
|
||||||
{
|
link: {
|
||||||
condition: !(
|
path: 'settings',
|
||||||
this.project.issues_url ||
|
title: 'Visit general settings',
|
||||||
this.project.source_url ||
|
hide: props.routeName === 'type-id-settings',
|
||||||
this.project.wiki_url ||
|
},
|
||||||
this.project.discord_url ||
|
},
|
||||||
this.project.donation_urls.length > 0
|
{
|
||||||
),
|
condition: props.project.gallery.length === 0 || !featuredGalleryImage,
|
||||||
title: 'Add external links',
|
title: 'Feature a gallery image',
|
||||||
id: 'add-links',
|
id: 'feature-gallery-image',
|
||||||
description:
|
description: 'Featured gallery images may be the first impression of many users.',
|
||||||
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
|
status: 'suggestion',
|
||||||
status: 'suggestion',
|
link: {
|
||||||
link: {
|
path: 'gallery',
|
||||||
path: 'settings/links',
|
title: 'Visit gallery page',
|
||||||
title: 'Visit links settings',
|
hide: props.routeName === 'type-id-gallery',
|
||||||
hide: this.routeName === 'type-id-settings-links',
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
hide: props.project.versions.length === 0,
|
||||||
hide:
|
condition: props.project.categories.length < 1,
|
||||||
this.project.project_type === 'resourcepack' ||
|
title: 'Select tags',
|
||||||
this.project.project_type === 'plugin' ||
|
id: 'select-tags',
|
||||||
this.project.project_type === 'shader' ||
|
description: 'Select all tags that apply to your project.',
|
||||||
this.project.project_type === 'datapack',
|
status: 'suggestion',
|
||||||
condition:
|
link: {
|
||||||
this.project.client_side === 'unknown' || this.project.server_side === 'unknown',
|
path: 'settings/tags',
|
||||||
title: 'Select supported environments',
|
title: 'Visit tag settings',
|
||||||
id: 'select-environments',
|
hide: props.routeName === 'type-id-settings-tags',
|
||||||
description: `Select if the ${this.$formatProjectType(
|
},
|
||||||
this.project.project_type
|
},
|
||||||
).toLowerCase()} functions on the client-side and/or server-side.`,
|
{
|
||||||
status: 'required',
|
condition: !(
|
||||||
link: {
|
props.project.issues_url ||
|
||||||
path: 'settings',
|
props.project.source_url ||
|
||||||
title: 'Visit general settings',
|
props.project.wiki_url ||
|
||||||
hide: this.routeName === 'type-id-settings',
|
props.project.discord_url ||
|
||||||
},
|
props.project.donation_urls.length > 0
|
||||||
},
|
),
|
||||||
{
|
title: 'Add external links',
|
||||||
condition: this.project.license.id === 'LicenseRef-Unknown',
|
id: 'add-links',
|
||||||
title: 'Select license',
|
description:
|
||||||
id: 'select-license',
|
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
|
||||||
description: `Select the license your ${this.$formatProjectType(
|
status: 'suggestion',
|
||||||
this.project.project_type
|
link: {
|
||||||
).toLowerCase()} is distributed under.`,
|
path: 'settings/links',
|
||||||
status: 'required',
|
title: 'Visit links settings',
|
||||||
link: {
|
hide: props.routeName === 'type-id-settings-links',
|
||||||
path: 'settings/license',
|
},
|
||||||
title: 'Visit license settings',
|
},
|
||||||
hide: this.routeName === 'type-id-settings-license',
|
{
|
||||||
},
|
hide:
|
||||||
},
|
props.project.versions.length === 0 ||
|
||||||
{
|
props.project.project_type === 'resourcepack' ||
|
||||||
hide: this.project.status !== 'draft',
|
props.project.project_type === 'plugin' ||
|
||||||
condition: true,
|
props.project.project_type === 'shader' ||
|
||||||
title: 'Submit for review',
|
props.project.project_type === 'datapack',
|
||||||
id: 'submit-for-review',
|
condition:
|
||||||
description:
|
props.project.client_side === 'unknown' ||
|
||||||
'Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.',
|
props.project.server_side === 'unknown' ||
|
||||||
status: 'review',
|
(props.project.client_side === 'unsupported' && props.project.server_side === 'unsupported'),
|
||||||
link: null,
|
title: 'Select supported environments',
|
||||||
action: {
|
id: 'select-environments',
|
||||||
onClick: this.submitForReview,
|
description: `Select if the ${formatProjectType(
|
||||||
title: 'Submit for review',
|
props.project.project_type
|
||||||
disabled: () =>
|
).toLowerCase()} functions on the client-side and/or server-side.`,
|
||||||
this.nags.filter((x) => x.condition && x.status === 'required').length > 0,
|
status: 'required',
|
||||||
},
|
link: {
|
||||||
},
|
path: 'settings',
|
||||||
{
|
title: 'Visit general settings',
|
||||||
hide: !this.tags.rejectedStatuses.includes(this.project.status),
|
hide: props.routeName === 'type-id-settings',
|
||||||
condition: true,
|
},
|
||||||
title: 'Resubmit for review',
|
},
|
||||||
id: 'resubmit-for-review',
|
{
|
||||||
description: `Your project has been ${this.project.status} by
|
condition: props.project.license.id === 'LicenseRef-Unknown',
|
||||||
|
title: 'Select license',
|
||||||
|
id: 'select-license',
|
||||||
|
description: `Select the license your ${formatProjectType(
|
||||||
|
props.project.project_type
|
||||||
|
).toLowerCase()} is distributed under.`,
|
||||||
|
status: 'required',
|
||||||
|
link: {
|
||||||
|
path: 'settings/license',
|
||||||
|
title: 'Visit license settings',
|
||||||
|
hide: props.routeName === 'type-id-settings-license',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: props.project.status === 'draft',
|
||||||
|
title: 'Submit for review',
|
||||||
|
id: 'submit-for-review',
|
||||||
|
description:
|
||||||
|
'Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.',
|
||||||
|
status: 'review',
|
||||||
|
link: null,
|
||||||
|
action: {
|
||||||
|
onClick: submitForReview,
|
||||||
|
title: 'Submit for review',
|
||||||
|
disabled: () => nags.value.filter((x) => x.condition && x.status === 'required').length > 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: props.tags.rejectedStatuses.includes(props.project.status),
|
||||||
|
title: 'Resubmit for review',
|
||||||
|
id: 'resubmit-for-review',
|
||||||
|
description: `Your project has been ${props.project.status} by
|
||||||
Modrinth's staff. In most cases, you can resubmit for review after
|
Modrinth's staff. In most cases, you can resubmit for review after
|
||||||
addressing the staff's message.`,
|
addressing the staff's message.`,
|
||||||
status: 'review',
|
status: 'review',
|
||||||
link: {
|
link: {
|
||||||
path: 'moderation',
|
path: 'moderation',
|
||||||
title: 'Visit moderation page',
|
title: 'Visit moderation page',
|
||||||
hide: this.routeName === 'type-id-moderation',
|
hide: props.routeName === 'type-id-moderation',
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
.filter((x) => !x.hide)
|
|
||||||
.sort((a, b) =>
|
|
||||||
this.sortByTrue(
|
|
||||||
!a.condition,
|
|
||||||
!b.condition,
|
|
||||||
this.sortByTrue(
|
|
||||||
a.status === 'required',
|
|
||||||
b.status === 'required',
|
|
||||||
this.sortByFalse(a.status === 'review', b.status === 'review')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
showInvitation() {
|
|
||||||
if (this.allMembers && this.auth) {
|
|
||||||
const member = this.allMembers.find((x) => x.user.id === this.auth.user.id)
|
|
||||||
return member && !member.accepted
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
acceptInvite() {
|
|
||||||
acceptTeamInvite(this.project.team)
|
|
||||||
this.updateMembers()
|
|
||||||
},
|
|
||||||
declineInvite() {
|
|
||||||
removeSelfFromTeam(this.project.team)
|
|
||||||
this.updateMembers()
|
|
||||||
},
|
|
||||||
sortByTrue(a, b, ifEqual = 0) {
|
|
||||||
if (a === b) {
|
|
||||||
return ifEqual
|
|
||||||
} else if (a) {
|
|
||||||
return -1
|
|
||||||
} else {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sortByFalse(a, b, ifEqual = 0) {
|
|
||||||
if (a === b) {
|
|
||||||
return ifEqual
|
|
||||||
} else if (b) {
|
|
||||||
return -1
|
|
||||||
} else {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async submitForReview() {
|
|
||||||
if (
|
|
||||||
!this.acknowledgedMessage ||
|
|
||||||
this.nags.filter((x) => x.condition && x.status === 'required').length === 0
|
|
||||||
) {
|
|
||||||
await this.setProcessing()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const showInvitation = computed(() => {
|
||||||
|
if (props.allMembers && props.auth) {
|
||||||
|
const member = props.allMembers.find((x) => x.user.id === props.auth.user.id)
|
||||||
|
return member && !member.accepted
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const acceptInvite = () => {
|
||||||
|
acceptTeamInvite(props.project.team)
|
||||||
|
props.updateMembers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const declineInvite = () => {
|
||||||
|
removeTeamMember(props.project.team, props.auth.user.id)
|
||||||
|
props.updateMembers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForReview = async () => {
|
||||||
|
if (
|
||||||
|
!props.acknowledgedMessage ||
|
||||||
|
nags.value.filter((x) => x.condition && x.status === 'required').length === 0
|
||||||
|
) {
|
||||||
|
await props.setProcessing()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -370,10 +370,11 @@ svg {
|
|||||||
|
|
||||||
.bar-chart {
|
.bar-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-bar {
|
.title-bar {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="analytics.error.value">
|
<div v-if="analytics.error.value" class="universal-card">
|
||||||
{{ analytics.error.value }}
|
<h2>
|
||||||
|
<span class="label__title">Error</span>
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
{{ analytics.error.value }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="graphs">
|
<div v-else class="graphs">
|
||||||
<div class="graphs__vertical-bar">
|
<div class="graphs__vertical-bar">
|
||||||
@@ -18,7 +23,7 @@
|
|||||||
:class="`clickable button-base ${
|
:class="`clickable button-base ${
|
||||||
selectedChart === 'downloads' ? 'button-base__selected' : ''
|
selectedChart === 'downloads' ? 'button-base__selected' : ''
|
||||||
}`"
|
}`"
|
||||||
:onclick="() => (selectedChart = 'downloads')"
|
:onclick="() => setSelectedChart('downloads')"
|
||||||
role="button"
|
role="button"
|
||||||
/>
|
/>
|
||||||
</client-only>
|
</client-only>
|
||||||
@@ -35,7 +40,7 @@
|
|||||||
:class="`clickable button-base ${
|
:class="`clickable button-base ${
|
||||||
selectedChart === 'views' ? 'button-base__selected' : ''
|
selectedChart === 'views' ? 'button-base__selected' : ''
|
||||||
}`"
|
}`"
|
||||||
:onclick="() => (selectedChart = 'views')"
|
:onclick="() => setSelectedChart('views')"
|
||||||
role="button"
|
role="button"
|
||||||
/>
|
/>
|
||||||
</client-only>
|
</client-only>
|
||||||
@@ -52,7 +57,7 @@
|
|||||||
:class="`clickable button-base ${
|
:class="`clickable button-base ${
|
||||||
selectedChart === 'revenue' ? 'button-base__selected' : ''
|
selectedChart === 'revenue' ? 'button-base__selected' : ''
|
||||||
}`"
|
}`"
|
||||||
:onclick="() => (selectedChart = 'revenue')"
|
:onclick="() => setSelectedChart('revenue')"
|
||||||
role="button"
|
role="button"
|
||||||
/>
|
/>
|
||||||
</client-only>
|
</client-only>
|
||||||
@@ -118,7 +123,9 @@
|
|||||||
<div class="country-data">
|
<div class="country-data">
|
||||||
<Card
|
<Card
|
||||||
v-if="
|
v-if="
|
||||||
analytics.formattedData.value?.downloadsByCountry && selectedChart === 'downloads'
|
analytics.formattedData.value?.downloadsByCountry &&
|
||||||
|
selectedChart === 'downloads' &&
|
||||||
|
analytics.formattedData.value.downloadsByCountry.data.length > 0
|
||||||
"
|
"
|
||||||
class="country-downloads"
|
class="country-downloads"
|
||||||
>
|
>
|
||||||
@@ -169,7 +176,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card
|
<Card
|
||||||
v-if="analytics.formattedData.value?.viewsByCountry && selectedChart === 'views'"
|
v-if="
|
||||||
|
analytics.formattedData.value?.viewsByCountry &&
|
||||||
|
selectedChart === 'views' &&
|
||||||
|
analytics.formattedData.value.viewsByCountry.data.length > 0
|
||||||
|
"
|
||||||
class="country-downloads"
|
class="country-downloads"
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
@@ -183,14 +194,20 @@
|
|||||||
>
|
>
|
||||||
<div class="country-flag-container">
|
<div class="country-flag-container">
|
||||||
<img
|
<img
|
||||||
:src="`https://flagcdn.com/h240/${name.toLowerCase()}.png`"
|
:src="
|
||||||
:alt="name"
|
name.toLowerCase() === 'xx' || !name
|
||||||
|
? 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||||
|
: countryCodeToFlag(name)
|
||||||
|
"
|
||||||
|
alt="Hidden country"
|
||||||
class="country-flag"
|
class="country-flag"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="country-text">
|
<div class="country-text">
|
||||||
<strong class="country-name">{{ countryCodeToName(name) }}</strong>
|
<strong class="country-name">
|
||||||
|
<template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
|
||||||
|
<template v-else>{{ countryCodeToName(name) }}</template>
|
||||||
|
</strong>
|
||||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -225,6 +242,8 @@ import dayjs from 'dayjs'
|
|||||||
import { defineProps, ref, computed } from 'vue'
|
import { defineProps, ref, computed } from 'vue'
|
||||||
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
|
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
projects?: any[]
|
projects?: any[]
|
||||||
@@ -247,7 +266,18 @@ const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) =>
|
|||||||
res: typeof extra === 'string' ? Number(duration) : extra[1],
|
res: typeof extra === 'string' ? Number(duration) : extra[1],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const selectedChart = ref('downloads')
|
// const selectedChart = ref('downloads')
|
||||||
|
const selectedChart = computed(() => {
|
||||||
|
return (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
|
||||||
|
})
|
||||||
|
const setSelectedChart = (chart: string) => {
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
...router.currentRoute.value.query,
|
||||||
|
chart,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Chart refs
|
// Chart refs
|
||||||
const downloadsChart = ref()
|
const downloadsChart = ref()
|
||||||
|
|||||||
10
composables/route-params.js
Normal file
10
composables/route-params.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Extracts the [id] from the route params and returns it as a ref.
|
||||||
|
*
|
||||||
|
* @param {string?} key The key of the route param to extract.
|
||||||
|
* @returns {import('vue').Ref<string | string[] | undefined>}
|
||||||
|
*/
|
||||||
|
export const useRouteId = (key = 'id') => {
|
||||||
|
const route = useRoute()
|
||||||
|
return route.params?.[key] || undefined
|
||||||
|
}
|
||||||
36
composables/use-client-try.ts
Normal file
36
composables/use-client-try.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
type AsyncFunction<TArgs extends any[], TResult> = (...args: TArgs) => Promise<TResult>
|
||||||
|
type ErrorFunction = (err: any) => void | Promise<void>
|
||||||
|
type VoidFunction = () => void | Promise<void>
|
||||||
|
|
||||||
|
type useClientTry = <TArgs extends any[], TResult>(
|
||||||
|
fn: AsyncFunction<TArgs, TResult>,
|
||||||
|
onFail?: ErrorFunction,
|
||||||
|
onFinish?: VoidFunction
|
||||||
|
) => (...args: TArgs) => Promise<TResult | undefined>
|
||||||
|
|
||||||
|
const defaultOnError: ErrorFunction = (error) => {
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'An error occurred',
|
||||||
|
text: error?.data?.description || error.message || error || 'Unknown error',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClientTry: useClientTry =
|
||||||
|
(fn, onFail = defaultOnError, onFinish) =>
|
||||||
|
async (...args) => {
|
||||||
|
startLoading()
|
||||||
|
try {
|
||||||
|
return await fn(...args)
|
||||||
|
} catch (err) {
|
||||||
|
if (onFail) {
|
||||||
|
await onFail(err)
|
||||||
|
} else {
|
||||||
|
console.error('[CLIENT TRY ERROR]', err)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (onFinish) await onFinish()
|
||||||
|
stopLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useNuxtApp } from '#app'
|
import { useNuxtApp } from '#app'
|
||||||
|
|
||||||
async function getBulk(type, ids) {
|
async function getBulk(type, ids, apiVersion = 2) {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`
|
const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`
|
||||||
const { data: bulkFetch } = await useAsyncData(url, () => useBaseFetch(url))
|
const { data: bulkFetch } = await useAsyncData(url, () => useBaseFetch(url, { apiVersion }))
|
||||||
return bulkFetch.value
|
return bulkFetch.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ export async function fetchNotifications() {
|
|||||||
const threadIds = []
|
const threadIds = []
|
||||||
const userIds = []
|
const userIds = []
|
||||||
const versionIds = []
|
const versionIds = []
|
||||||
|
const organizationIds = []
|
||||||
|
|
||||||
for (const notification of notifications.value) {
|
for (const notification of notifications.value) {
|
||||||
if (notification.body) {
|
if (notification.body) {
|
||||||
@@ -40,6 +41,9 @@ export async function fetchNotifications() {
|
|||||||
if (notification.body.invited_by) {
|
if (notification.body.invited_by) {
|
||||||
userIds.push(notification.body.invited_by)
|
userIds.push(notification.body.invited_by)
|
||||||
}
|
}
|
||||||
|
if (notification.body.organization_id) {
|
||||||
|
organizationIds.push(notification.body.organization_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +65,12 @@ export async function fetchNotifications() {
|
|||||||
projectIds.push(version.project_id)
|
projectIds.push(version.project_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getBulk('projects', projectIds)
|
const [projects, threads, users, organizations] = await Promise.all([
|
||||||
const threads = await getBulk('threads', threadIds)
|
getBulk('projects', projectIds),
|
||||||
const users = await getBulk('users', userIds)
|
getBulk('threads', threadIds),
|
||||||
|
getBulk('users', userIds),
|
||||||
|
getBulk('organizations', organizationIds, 3),
|
||||||
|
])
|
||||||
|
|
||||||
for (const notification of notifications.value) {
|
for (const notification of notifications.value) {
|
||||||
notification.extra_data = {}
|
notification.extra_data = {}
|
||||||
@@ -73,6 +80,11 @@ export async function fetchNotifications() {
|
|||||||
(x) => x.id === notification.body.project_id
|
(x) => x.id === notification.body.project_id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (notification.body.organization_id) {
|
||||||
|
notification.extra_data.organization = organizations.find(
|
||||||
|
(x) => x.id === notification.body.organization_id
|
||||||
|
)
|
||||||
|
}
|
||||||
if (notification.body.report_id) {
|
if (notification.body.report_id) {
|
||||||
notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id)
|
notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const acceptTeamInvite = async (teamId) => {
|
export const acceptTeamInvite = async (teamId) => {
|
||||||
await useBaseFetch(`team/${teamId}/join`, {
|
await useBaseFetch(`team/${teamId}/join`, {
|
||||||
|
apiVersion: 3,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -9,6 +10,7 @@ export const removeSelfFromTeam = async (teamId) => {
|
|||||||
}
|
}
|
||||||
export const removeTeamMember = async (teamId, userId) => {
|
export const removeTeamMember = async (teamId, userId) => {
|
||||||
await useBaseFetch(`team/${teamId}/members/${userId}`, {
|
await useBaseFetch(`team/${teamId}/members/${userId}`, {
|
||||||
|
apiVersion: 3,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,10 @@
|
|||||||
<span class="title">Create a project</span>
|
<span class="title">Create a project</span>
|
||||||
</button>
|
</button>
|
||||||
<hr class="divider" />
|
<hr class="divider" />
|
||||||
|
<NuxtLink class="item button-transparent" to="/dashboard/collections">
|
||||||
|
<LibraryIcon class="icon" />
|
||||||
|
<span class="title">Collections</span>
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink class="item button-transparent" to="/dashboard/notifications">
|
<NuxtLink class="item button-transparent" to="/dashboard/notifications">
|
||||||
<NotificationIcon class="icon" />
|
<NotificationIcon class="icon" />
|
||||||
<span class="title">Notifications</span>
|
<span class="title">Notifications</span>
|
||||||
@@ -85,10 +89,6 @@
|
|||||||
<ChartIcon class="icon" />
|
<ChartIcon class="icon" />
|
||||||
<span class="title">Dashboard</span>
|
<span class="title">Dashboard</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="item button-transparent" to="/dashboard/collections">
|
|
||||||
<LibraryIcon class="icon" />
|
|
||||||
<span class="title">Collections</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink class="item button-transparent" to="/settings">
|
<NuxtLink class="item button-transparent" to="/settings">
|
||||||
<SettingsIcon class="icon" />
|
<SettingsIcon class="icon" />
|
||||||
<span class="title">Settings</span>
|
<span class="title">Settings</span>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"omorphia": "=0.7.1",
|
"omorphia": "=0.7.2",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"vue-multiselect": "^3.0.0-alpha.2",
|
"vue-multiselect": "^3.0.0-alpha.2",
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
current-title="Settings"
|
current-title="Settings"
|
||||||
:link-stack="[
|
:link-stack="[
|
||||||
{ href: `/dashboard/projects`, label: 'Projects' },
|
{
|
||||||
|
href: organization
|
||||||
|
? `/organization/${organization.slug}/settings/projects`
|
||||||
|
: `/dashboard/projects`,
|
||||||
|
label: 'Projects',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: `/${project.project_type}/${project.slug ? project.slug : project.id}`,
|
href: `/${project.project_type}/${project.slug ? project.slug : project.id}`,
|
||||||
label: project.title,
|
label: project.title,
|
||||||
@@ -126,10 +131,13 @@
|
|||||||
v-model:members="members"
|
v-model:members="members"
|
||||||
v-model:all-members="allMembers"
|
v-model:all-members="allMembers"
|
||||||
v-model:dependencies="dependencies"
|
v-model:dependencies="dependencies"
|
||||||
|
v-model:organization="organization"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember"
|
||||||
:patch-project="patchProject"
|
:patch-project="patchProject"
|
||||||
:patch-icon="patchIcon"
|
:patch-icon="patchIcon"
|
||||||
:update-icon="resetProject"
|
:reset-project="resetProject"
|
||||||
|
:reset-organization="resetOrganization"
|
||||||
|
:reset-members="resetMembers"
|
||||||
:route="route"
|
:route="route"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,11 +486,15 @@
|
|||||||
v-model:members="members"
|
v-model:members="members"
|
||||||
v-model:all-members="allMembers"
|
v-model:all-members="allMembers"
|
||||||
v-model:dependencies="dependencies"
|
v-model:dependencies="dependencies"
|
||||||
|
v-model:organization="organization"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember"
|
||||||
|
:reset-project="resetProject"
|
||||||
|
:reset-organization="resetOrganization"
|
||||||
|
:reset-members="resetMembers"
|
||||||
:route="route"
|
:route="route"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<div class="card normal-page__info">
|
<div class="universal-card normal-page__info">
|
||||||
<template
|
<template
|
||||||
v-if="
|
v-if="
|
||||||
project.issues_url ||
|
project.issues_url ||
|
||||||
@@ -618,6 +630,17 @@
|
|||||||
<hr class="card-divider" />
|
<hr class="card-divider" />
|
||||||
</template>
|
</template>
|
||||||
<h2 class="card-header">Project members</h2>
|
<h2 class="card-header">Project members</h2>
|
||||||
|
<nuxt-link
|
||||||
|
v-if="organization"
|
||||||
|
class="team-member columns button-transparent"
|
||||||
|
:to="`/organization/${organization.slug}`"
|
||||||
|
>
|
||||||
|
<Avatar :src="organization.icon_url" :alt="organization.name" size="sm" />
|
||||||
|
<div class="member-info">
|
||||||
|
<p class="name">{{ organization.name }}</p>
|
||||||
|
<p class="role"><OrganizationIcon /> Organization</p>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
<nuxt-link
|
<nuxt-link
|
||||||
v-for="member in members"
|
v-for="member in members"
|
||||||
:key="member.user.id"
|
:key="member.user.id"
|
||||||
@@ -787,6 +810,7 @@ import { reportProject } from '~/utils/report-helpers.ts'
|
|||||||
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||||
import { userCollectProject } from '~/composables/user.js'
|
import { userCollectProject } from '~/composables/user.js'
|
||||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||||
|
import OrganizationIcon from '~/assets/images/utils/organization.svg'
|
||||||
|
|
||||||
const data = useNuxtApp()
|
const data = useNuxtApp()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -820,14 +844,23 @@ if (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let project, allMembers, dependencies, featuredVersions, versions
|
let project,
|
||||||
|
resetProject,
|
||||||
|
allMembers,
|
||||||
|
resetMembers,
|
||||||
|
dependencies,
|
||||||
|
featuredVersions,
|
||||||
|
versions,
|
||||||
|
organization,
|
||||||
|
resetOrganization
|
||||||
try {
|
try {
|
||||||
;[
|
;[
|
||||||
{ data: project },
|
{ data: project, refresh: resetProject },
|
||||||
{ data: allMembers },
|
{ data: allMembers, refresh: resetMembers },
|
||||||
{ data: dependencies },
|
{ data: dependencies },
|
||||||
{ data: featuredVersions },
|
{ data: featuredVersions },
|
||||||
{ data: versions },
|
{ data: versions },
|
||||||
|
{ data: organization, refresh: resetOrganization },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
useAsyncData(`project/${route.params.id}`, () => useBaseFetch(`project/${route.params.id}`), {
|
useAsyncData(`project/${route.params.id}`, () => useBaseFetch(`project/${route.params.id}`), {
|
||||||
transform: (project) => {
|
transform: (project) => {
|
||||||
@@ -838,10 +871,6 @@ try {
|
|||||||
project.loaders,
|
project.loaders,
|
||||||
tags.value
|
tags.value
|
||||||
)
|
)
|
||||||
|
|
||||||
if (process.client && history.state && history.state.overrideProjectType) {
|
|
||||||
project.project_type = history.state.overrideProjectType
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return project
|
return project
|
||||||
@@ -870,6 +899,9 @@ try {
|
|||||||
useAsyncData(`project/${route.params.id}/version`, () =>
|
useAsyncData(`project/${route.params.id}/version`, () =>
|
||||||
useBaseFetch(`project/${route.params.id}/version`)
|
useBaseFetch(`project/${route.params.id}/version`)
|
||||||
),
|
),
|
||||||
|
useAsyncData(`project/${route.params.id}/organization`, () =>
|
||||||
|
useBaseFetch(`project/${route.params.id}/organization`, { apiVersion: 3 })
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
versions = shallowRef(toRaw(versions))
|
versions = shallowRef(toRaw(versions))
|
||||||
@@ -903,27 +935,46 @@ if (project.value.project_type !== route.params.type || route.params.id !== proj
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = ref(allMembers.value.filter((x) => x.accepted))
|
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
|
||||||
const currentMember = ref(
|
// The rest of the members should be sorted by role, then by name
|
||||||
auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null
|
const members = computed(() => {
|
||||||
)
|
const acceptedMembers = allMembers.value.filter((x) => x.accepted)
|
||||||
|
const owner = acceptedMembers.find((x) => x.role === 'Owner')
|
||||||
|
const rest = acceptedMembers.filter((x) => x.role !== 'Owner') || []
|
||||||
|
|
||||||
if (
|
rest.sort((a, b) => {
|
||||||
!currentMember.value &&
|
if (a.role === b.role) {
|
||||||
auth.value.user &&
|
return a.user.username.localeCompare(b.user.username)
|
||||||
tags.value.staffRoles.includes(auth.value.user.role)
|
} else {
|
||||||
) {
|
return a.role.localeCompare(b.role)
|
||||||
currentMember.value = {
|
}
|
||||||
team_id: project.team_id,
|
})
|
||||||
user: auth.value.user,
|
|
||||||
role: auth.value.role,
|
return owner ? [owner, ...rest] : rest
|
||||||
permissions: auth.value.user.role === 'admin' ? 1023 : 12,
|
})
|
||||||
accepted: true,
|
|
||||||
payouts_split: 0,
|
const currentMember = computed(() => {
|
||||||
avatar_url: auth.value.user.avatar_url,
|
let val = auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null
|
||||||
name: auth.value.user.username,
|
|
||||||
|
if (!val && organization.value && organization.value.members) {
|
||||||
|
val = organization.value.members.find((x) => x.user.id === auth.value.user.id)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (!val && auth.value.user && tags.value.staffRoles.includes(auth.value.user.role)) {
|
||||||
|
val = {
|
||||||
|
team_id: project.team_id,
|
||||||
|
user: auth.value.user,
|
||||||
|
role: auth.value.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 val
|
||||||
|
})
|
||||||
|
|
||||||
versions.value = data.$computeVersions(versions.value, allMembers.value)
|
versions.value = data.$computeVersions(versions.value, allMembers.value)
|
||||||
|
|
||||||
@@ -955,38 +1006,36 @@ const licenseIdDisplay = computed(() => {
|
|||||||
})
|
})
|
||||||
const featuredGalleryImage = computed(() => project.value.gallery.find((img) => img.featured))
|
const featuredGalleryImage = computed(() => project.value.gallery.find((img) => img.featured))
|
||||||
|
|
||||||
const projectTypeDisplay = data.$formatProjectType(
|
const projectTypeDisplay = computed(() =>
|
||||||
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders)
|
data.$formatProjectType(
|
||||||
|
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const title = computed(() => `${project.value.title} - Minecraft ${projectTypeDisplay.value}`)
|
||||||
|
const description = computed(
|
||||||
|
() =>
|
||||||
|
`${project.value.description} - Download the Minecraft ${projectTypeDisplay.value} ${
|
||||||
|
project.value.title
|
||||||
|
} by ${
|
||||||
|
members.value.find((x) => x.role === 'Owner')?.user?.username || 'a Creator'
|
||||||
|
} on Modrinth`
|
||||||
)
|
)
|
||||||
const title = `${project.value.title} - Minecraft ${projectTypeDisplay}`
|
|
||||||
const description = `${project.value.description} - Download the Minecraft ${projectTypeDisplay} ${
|
|
||||||
project.value.title
|
|
||||||
} by ${members.value.find((x) => x.role === 'Owner').user.username} on Modrinth`
|
|
||||||
|
|
||||||
if (!route.name.startsWith('type-id-settings')) {
|
if (!route.name.startsWith('type-id-settings')) {
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title,
|
title: () => title.value,
|
||||||
description,
|
description: () => description.value,
|
||||||
ogTitle: title,
|
ogTitle: () => title.value,
|
||||||
ogDescription: project.value.description,
|
ogDescription: () => project.value.description,
|
||||||
ogImage: project.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
ogImage: () => project.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||||
robots:
|
robots: () =>
|
||||||
project.value.status === 'approved' || project.value.status === 'archived'
|
project.value.status === 'approved' || project.value.status === 'archived'
|
||||||
? 'all'
|
? 'all'
|
||||||
: 'noindex',
|
: 'noindex',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetProject() {
|
|
||||||
const newProject = await useBaseFetch(`project/${project.value.id}`)
|
|
||||||
|
|
||||||
newProject.actualProjectType = JSON.parse(JSON.stringify(newProject.project_type))
|
|
||||||
|
|
||||||
newProject.project_type = data.$getProjectTypeForUrl(newProject.project_type, newProject.loaders)
|
|
||||||
|
|
||||||
project.value = newProject
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearMessage() {
|
async function clearMessage() {
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
@@ -1041,7 +1090,7 @@ const licenseText = ref('')
|
|||||||
async function getLicenseData() {
|
async function getLicenseData() {
|
||||||
try {
|
try {
|
||||||
const text = await useBaseFetch(`tag/license/${project.value.license.id}`)
|
const text = await useBaseFetch(`tag/license/${project.value.license.id}`)
|
||||||
licenseText.value = text.body
|
licenseText.value = text.body || 'License text could not be retrieved.'
|
||||||
} catch {
|
} catch {
|
||||||
licenseText.value = 'License text could not be retrieved.'
|
licenseText.value = 'License text could not be retrieved.'
|
||||||
}
|
}
|
||||||
@@ -1364,6 +1413,12 @@ const collapsedChecklist = ref(false)
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
margin: 0.2rem 0;
|
margin: 0.2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,8 @@
|
|||||||
:max-size="524288000"
|
:max-size="524288000"
|
||||||
:accept="acceptFileTypes"
|
:accept="acceptFileTypes"
|
||||||
prompt="Upload an image"
|
prompt="Upload an image"
|
||||||
class="brand-button iconified-button"
|
class="iconified-button brand-button"
|
||||||
|
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
|
||||||
@change="handleFiles"
|
@change="handleFiles"
|
||||||
>
|
>
|
||||||
<UploadIcon />
|
<UploadIcon />
|
||||||
@@ -207,7 +208,14 @@
|
|||||||
<span class="indicator">
|
<span class="indicator">
|
||||||
<InfoIcon /> Click to choose an image or drag one onto this page
|
<InfoIcon /> Click to choose an image or drag one onto this page
|
||||||
</span>
|
</span>
|
||||||
<DropArea :accept="acceptFileTypes" @change="handleFiles" />
|
<DropArea
|
||||||
|
:accept="acceptFileTypes"
|
||||||
|
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
|
||||||
|
@change="handleFiles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card header-buttons">
|
||||||
|
<span class="indicator"> <InfoIcon /> You don't have permission to upload images </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="items">
|
<div class="items">
|
||||||
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||||
@@ -288,11 +296,13 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
TransferIcon,
|
TransferIcon,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
FileInput,
|
|
||||||
DropArea,
|
|
||||||
} from 'omorphia'
|
} from 'omorphia'
|
||||||
|
import FileInput from '~/components/ui/FileInput.vue'
|
||||||
|
import DropArea from '~/components/ui/DropArea.vue'
|
||||||
import Modal from '~/components/ui/Modal.vue'
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
|
||||||
|
import { isPermission } from '~/utils/permissions.ts'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
project: {
|
project: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -306,6 +316,11 @@ const props = defineProps({
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resetProject: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = `${props.project.title} - Gallery`
|
const title = `${props.project.title} - Gallery`
|
||||||
@@ -430,7 +445,7 @@ export default defineNuxtComponent({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: this.editFile,
|
body: this.editFile,
|
||||||
})
|
})
|
||||||
await this.updateProject()
|
await this.resetProject()
|
||||||
|
|
||||||
this.$refs.modal_edit_item.hide()
|
this.$refs.modal_edit_item.hide()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -468,7 +483,7 @@ export default defineNuxtComponent({
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.updateProject()
|
await this.resetProject()
|
||||||
this.$refs.modal_edit_item.hide()
|
this.$refs.modal_edit_item.hide()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$notify({
|
this.$notify({
|
||||||
@@ -495,7 +510,7 @@ export default defineNuxtComponent({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await this.updateProject()
|
await this.resetProject()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$notify({
|
this.$notify({
|
||||||
group: 'main',
|
group: 'main',
|
||||||
@@ -507,16 +522,6 @@ export default defineNuxtComponent({
|
|||||||
|
|
||||||
stopLoading()
|
stopLoading()
|
||||||
},
|
},
|
||||||
async updateProject() {
|
|
||||||
const project = await useBaseFetch(`project/${this.project.id}`)
|
|
||||||
|
|
||||||
project.actualProjectType = JSON.parse(JSON.stringify(project.project_type))
|
|
||||||
|
|
||||||
project.project_type = this.$getProjectTypeForUrl(project.project_type, project.loaders)
|
|
||||||
|
|
||||||
this.$emit('update:project', project)
|
|
||||||
this.resetEdit()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="markdown-body card" v-html="renderHighlightedString(project.body)" />
|
<div
|
||||||
|
v-if="project.body"
|
||||||
|
class="markdown-body card"
|
||||||
|
v-html="renderHighlightedString(project.body || '')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -104,10 +104,13 @@ const props = defineProps({
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resetProject: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:project'])
|
|
||||||
|
|
||||||
const app = useNuxtApp()
|
const app = useNuxtApp()
|
||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
|
|
||||||
@@ -130,7 +133,7 @@ async function setStatus(status) {
|
|||||||
})
|
})
|
||||||
const project = props.project
|
const project = props.project
|
||||||
project.status = status
|
project.status = status
|
||||||
emit('update:project', project)
|
await props.resetProject()
|
||||||
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
|
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.$notify({
|
app.$notify({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
<div class="universal-card">
|
||||||
<div class="markdown-disclaimer">
|
<div class="markdown-disclaimer">
|
||||||
<h2>Description</h2>
|
<h2>Description</h2>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
@@ -29,12 +29,12 @@
|
|||||||
Save changes
|
Save changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { MarkdownEditor, Card } from 'omorphia'
|
import { MarkdownEditor } from 'omorphia'
|
||||||
import Chips from '~/components/ui/Chips.vue'
|
import Chips from '~/components/ui/Chips.vue'
|
||||||
import SaveIcon from '~/assets/images/utils/save.svg'
|
import SaveIcon from '~/assets/images/utils/save.svg'
|
||||||
import { renderHighlightedString } from '~/helpers/highlight.js'
|
import { renderHighlightedString } from '~/helpers/highlight.js'
|
||||||
@@ -42,7 +42,6 @@ import { useImageUpload } from '~/composables/image-upload.ts'
|
|||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
Card,
|
|
||||||
Chips,
|
Chips,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
MarkdownEditor,
|
MarkdownEditor,
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<template
|
<template
|
||||||
v-if="
|
v-if="
|
||||||
|
project.versions.length !== 0 &&
|
||||||
project.project_type !== 'resourcepack' &&
|
project.project_type !== 'resourcepack' &&
|
||||||
project.project_type !== 'plugin' &&
|
project.project_type !== 'plugin' &&
|
||||||
project.project_type !== 'shader' &&
|
project.project_type !== 'shader' &&
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
<Multiselect
|
<Multiselect
|
||||||
id="project-env-client"
|
id="project-env-client"
|
||||||
v-model="clientSide"
|
v-model="clientSide"
|
||||||
|
class="small-multiselect"
|
||||||
placeholder="Select one"
|
placeholder="Select one"
|
||||||
:options="sideTypes"
|
:options="sideTypes"
|
||||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||||
@@ -133,6 +135,7 @@
|
|||||||
<Multiselect
|
<Multiselect
|
||||||
id="project-env-server"
|
id="project-env-server"
|
||||||
v-model="serverSide"
|
v-model="serverSide"
|
||||||
|
class="small-multiselect"
|
||||||
placeholder="Select one"
|
placeholder="Select one"
|
||||||
:options="sideTypes"
|
:options="sideTypes"
|
||||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||||
@@ -190,6 +193,7 @@
|
|||||||
<Multiselect
|
<Multiselect
|
||||||
id="project-visibility"
|
id="project-visibility"
|
||||||
v-model="visibility"
|
v-model="visibility"
|
||||||
|
class="small-multiselect"
|
||||||
placeholder="Select one"
|
placeholder="Select one"
|
||||||
:options="tags.approvedStatuses"
|
:options="tags.approvedStatuses"
|
||||||
:custom-label="(value) => $formatProjectStatus(value)"
|
:custom-label="(value) => $formatProjectStatus(value)"
|
||||||
@@ -236,8 +240,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { Multiselect } from 'vue-multiselect'
|
import { Multiselect } from 'vue-multiselect'
|
||||||
|
|
||||||
import Avatar from '~/components/ui/Avatar.vue'
|
import Avatar from '~/components/ui/Avatar.vue'
|
||||||
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
||||||
import FileInput from '~/components/ui/FileInput.vue'
|
import FileInput from '~/components/ui/FileInput.vue'
|
||||||
@@ -249,198 +254,160 @@ import ExitIcon from '~/assets/images/utils/x.svg'
|
|||||||
import IssuesIcon from '~/assets/images/utils/issues.svg'
|
import IssuesIcon from '~/assets/images/utils/issues.svg'
|
||||||
import CheckIcon from '~/assets/images/utils/check.svg'
|
import CheckIcon from '~/assets/images/utils/check.svg'
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps({
|
||||||
components: {
|
project: {
|
||||||
Avatar,
|
type: Object,
|
||||||
ModalConfirm,
|
required: true,
|
||||||
FileInput,
|
default: () => ({}),
|
||||||
Multiselect,
|
|
||||||
UploadIcon,
|
|
||||||
SaveIcon,
|
|
||||||
TrashIcon,
|
|
||||||
ExitIcon,
|
|
||||||
CheckIcon,
|
|
||||||
IssuesIcon,
|
|
||||||
},
|
},
|
||||||
props: {
|
currentMember: {
|
||||||
project: {
|
type: Object,
|
||||||
type: Object,
|
required: true,
|
||||||
default() {
|
default: () => ({}),
|
||||||
return {}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
currentMember: {
|
|
||||||
type: Object,
|
|
||||||
default() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
patchProject: {
|
|
||||||
type: Function,
|
|
||||||
default() {
|
|
||||||
return () => {
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'An error occurred',
|
|
||||||
text: 'Patch project function not found',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
patchIcon: {
|
|
||||||
type: Function,
|
|
||||||
default() {
|
|
||||||
return () => {
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'An error occurred',
|
|
||||||
text: 'Patch icon function not found',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
updateIcon: {
|
|
||||||
type: Function,
|
|
||||||
default() {
|
|
||||||
return () => {
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'An error occurred',
|
|
||||||
text: 'Update icon function not found',
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup() {
|
patchProject: {
|
||||||
const tags = useTags()
|
type: Function,
|
||||||
|
required: true,
|
||||||
return { tags }
|
default: () => {},
|
||||||
},
|
},
|
||||||
data() {
|
patchIcon: {
|
||||||
return {
|
type: Function,
|
||||||
name: this.project.title,
|
required: true,
|
||||||
slug: this.project.slug,
|
default: () => {},
|
||||||
summary: this.project.description,
|
|
||||||
icon: null,
|
|
||||||
previewImage: null,
|
|
||||||
clientSide: this.project.client_side,
|
|
||||||
serverSide: this.project.server_side,
|
|
||||||
deletedIcon: false,
|
|
||||||
visibility: this.tags.approvedStatuses.includes(this.project.status)
|
|
||||||
? this.project.status
|
|
||||||
: this.project.requested_status,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
resetProject: {
|
||||||
hasPermission() {
|
type: Function,
|
||||||
const EDIT_DETAILS = 1 << 2
|
required: true,
|
||||||
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
default: () => {},
|
||||||
},
|
|
||||||
hasDeletePermission() {
|
|
||||||
const DELETE_PROJECT = 1 << 7
|
|
||||||
return (this.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
|
|
||||||
},
|
|
||||||
sideTypes() {
|
|
||||||
return ['required', 'optional', 'unsupported']
|
|
||||||
},
|
|
||||||
patchData() {
|
|
||||||
const data = {}
|
|
||||||
|
|
||||||
if (this.name !== this.project.title) {
|
|
||||||
data.title = this.name.trim()
|
|
||||||
}
|
|
||||||
if (this.slug !== this.project.slug) {
|
|
||||||
data.slug = this.slug.trim()
|
|
||||||
}
|
|
||||||
if (this.summary !== this.project.description) {
|
|
||||||
data.description = this.summary.trim()
|
|
||||||
}
|
|
||||||
if (this.clientSide !== this.project.client_side) {
|
|
||||||
data.client_side = this.clientSide
|
|
||||||
}
|
|
||||||
if (this.serverSide !== this.project.server_side) {
|
|
||||||
data.server_side = this.serverSide
|
|
||||||
}
|
|
||||||
if (this.tags.approvedStatuses.includes(this.project.status)) {
|
|
||||||
if (this.visibility !== this.project.status) {
|
|
||||||
data.status = this.visibility
|
|
||||||
}
|
|
||||||
} else if (this.visibility !== this.project.requested_status) {
|
|
||||||
data.requested_status = this.visibility
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
hasChanges() {
|
|
||||||
return Object.keys(this.patchData).length > 0 || this.deletedIcon || this.icon
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
hasModifiedVisibility() {
|
|
||||||
const originalVisibility = this.tags.approvedStatuses.includes(this.project.status)
|
|
||||||
? this.project.status
|
|
||||||
: this.project.requested_status
|
|
||||||
|
|
||||||
return originalVisibility !== this.visibility
|
|
||||||
},
|
|
||||||
async saveChanges() {
|
|
||||||
if (this.hasChanges) {
|
|
||||||
await this.patchProject(this.patchData)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.deletedIcon) {
|
|
||||||
await this.deleteIcon()
|
|
||||||
this.deletedIcon = false
|
|
||||||
} else if (this.icon) {
|
|
||||||
await this.patchIcon(this.icon)
|
|
||||||
this.icon = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showPreviewImage(files) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
this.icon = files[0]
|
|
||||||
this.deletedIcon = false
|
|
||||||
reader.readAsDataURL(this.icon)
|
|
||||||
reader.onload = (event) => {
|
|
||||||
this.previewImage = event.target.result
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async deleteProject() {
|
|
||||||
await useBaseFetch(`project/${this.project.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
await initUserProjects()
|
|
||||||
await this.$router.push('/dashboard/projects')
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'Project deleted',
|
|
||||||
text: 'Your project has been deleted.',
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
markIconForDeletion() {
|
|
||||||
this.deletedIcon = true
|
|
||||||
this.icon = null
|
|
||||||
this.previewImage = null
|
|
||||||
},
|
|
||||||
async deleteIcon() {
|
|
||||||
await useBaseFetch(`project/${this.project.id}/icon`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
await this.updateIcon()
|
|
||||||
this.$notify({
|
|
||||||
group: 'main',
|
|
||||||
title: 'Project icon removed',
|
|
||||||
text: "Your project's icon has been removed.",
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tags = useTags()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const name = ref(props.project.title)
|
||||||
|
const slug = ref(props.project.slug)
|
||||||
|
const summary = ref(props.project.description)
|
||||||
|
const icon = ref(null)
|
||||||
|
const previewImage = ref(null)
|
||||||
|
const clientSide = ref(props.project.client_side)
|
||||||
|
const serverSide = ref(props.project.server_side)
|
||||||
|
const deletedIcon = ref(false)
|
||||||
|
const visibility = ref(
|
||||||
|
tags.value.approvedStatuses.includes(props.project.status)
|
||||||
|
? props.project.status
|
||||||
|
: props.project.requested_status
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasPermission = computed(() => {
|
||||||
|
const EDIT_DETAILS = 1 << 2
|
||||||
|
return (props.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasDeletePermission = computed(() => {
|
||||||
|
const DELETE_PROJECT = 1 << 7
|
||||||
|
return (props.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
|
||||||
|
})
|
||||||
|
|
||||||
|
const sideTypes = ['required', 'optional', 'unsupported']
|
||||||
|
|
||||||
|
const patchData = computed(() => {
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
if (name.value !== props.project.title) {
|
||||||
|
data.title = name.value.trim()
|
||||||
|
}
|
||||||
|
if (slug.value !== props.project.slug) {
|
||||||
|
data.slug = slug.value.trim()
|
||||||
|
}
|
||||||
|
if (summary.value !== props.project.description) {
|
||||||
|
data.description = summary.value.trim()
|
||||||
|
}
|
||||||
|
if (clientSide.value !== props.project.client_side) {
|
||||||
|
data.client_side = clientSide.value
|
||||||
|
}
|
||||||
|
if (serverSide.value !== props.project.server_side) {
|
||||||
|
data.server_side = serverSide.value
|
||||||
|
}
|
||||||
|
if (tags.value.approvedStatuses.includes(props.project.status)) {
|
||||||
|
if (visibility.value !== props.project.status) {
|
||||||
|
data.status = visibility.value
|
||||||
|
}
|
||||||
|
} else if (visibility.value !== props.project.requested_status) {
|
||||||
|
data.requested_status = visibility.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasModifiedVisibility = () => {
|
||||||
|
const originalVisibility = tags.value.approvedStatuses.includes(props.project.status)
|
||||||
|
? props.project.status
|
||||||
|
: props.project.requested_status
|
||||||
|
|
||||||
|
return originalVisibility !== visibility.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveChanges = async () => {
|
||||||
|
if (hasChanges.value) {
|
||||||
|
await props.patchProject(patchData.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedIcon.value) {
|
||||||
|
await deleteIcon()
|
||||||
|
deletedIcon.value = false
|
||||||
|
} else if (icon.value) {
|
||||||
|
await props.patchIcon(icon.value)
|
||||||
|
icon.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPreviewImage = (files) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
icon.value = files[0]
|
||||||
|
deletedIcon.value = false
|
||||||
|
reader.readAsDataURL(icon.value)
|
||||||
|
reader.onload = (event) => {
|
||||||
|
previewImage.value = event.target.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteProject = async () => {
|
||||||
|
await useBaseFetch(`project/${props.project.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
await initUserProjects()
|
||||||
|
await router.push('/dashboard/projects')
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Project deleted',
|
||||||
|
text: 'Your project has been deleted.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const markIconForDeletion = () => {
|
||||||
|
deletedIcon.value = true
|
||||||
|
icon.value = null
|
||||||
|
previewImage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteIcon = async () => {
|
||||||
|
await useBaseFetch(`project/${props.project.id}/icon`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
await props.resetProject()
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Project icon removed',
|
||||||
|
text: "Your project's icon has been removed.",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.visibility-info {
|
.visibility-info {
|
||||||
@@ -467,7 +434,7 @@ svg {
|
|||||||
max-width: 24rem;
|
max-width: 24rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multiselect {
|
.small-multiselect {
|
||||||
max-width: 15rem;
|
max-width: 15rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,44 +11,82 @@
|
|||||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
|
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
|
||||||
that apply.
|
that apply.
|
||||||
</p>
|
</p>
|
||||||
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
|
<p v-if="project.versions.length === 0" class="known-errors">
|
||||||
|
Please upload a version first in order to select tags!
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
|
||||||
|
<div class="label">
|
||||||
|
<h4>
|
||||||
|
<span class="label__title">{{ $formatCategoryHeader(header) }}</span>
|
||||||
|
</h4>
|
||||||
|
<span class="label__description">
|
||||||
|
<template v-if="header === 'categories'">
|
||||||
|
Select all categories that reflect the themes or function of your
|
||||||
|
{{ $formatProjectType(project.project_type).toLowerCase() }}.
|
||||||
|
</template>
|
||||||
|
<template v-else-if="header === 'features'">
|
||||||
|
Select all of the features that your
|
||||||
|
{{ $formatProjectType(project.project_type).toLowerCase() }} makes use of.
|
||||||
|
</template>
|
||||||
|
<template v-else-if="header === 'resolutions'">
|
||||||
|
Select the resolution(s) of textures in your
|
||||||
|
{{ $formatProjectType(project.project_type).toLowerCase() }}.
|
||||||
|
</template>
|
||||||
|
<template v-else-if="header === 'performance impact'">
|
||||||
|
Select the realistic performance impact of your
|
||||||
|
{{ $formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
|
||||||
|
{{ $formatProjectType(project.project_type).toLowerCase() }} is configurable to
|
||||||
|
different levels of performance impact.
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="category-list input-div">
|
||||||
|
<Checkbox
|
||||||
|
v-for="category in categoryLists[header]"
|
||||||
|
:key="`category-${header}-${category.name}`"
|
||||||
|
:model-value="selectedTags.includes(category)"
|
||||||
|
:description="$formatCategory(category.name)"
|
||||||
|
class="category-selector"
|
||||||
|
@update:model-value="toggleCategory(category)"
|
||||||
|
>
|
||||||
|
<div class="category-selector__label">
|
||||||
|
<div
|
||||||
|
v-if="header !== 'resolutions' && category.icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="icon"
|
||||||
|
v-html="category.icon"
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
|
||||||
|
</div>
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<h4>
|
<h4>
|
||||||
<span class="label__title">{{ $formatCategoryHeader(header) }}</span>
|
<span class="label__title"><StarIcon /> Featured tags</span>
|
||||||
</h4>
|
</h4>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
<template v-if="header === 'categories'">
|
You can feature up to 3 of your most relevant tags. Other tags may be promoted to
|
||||||
Select all categories that reflect the themes or function of your
|
featured if you do not select all 3.
|
||||||
{{ $formatProjectType(project.project_type).toLowerCase() }}.
|
|
||||||
</template>
|
|
||||||
<template v-else-if="header === 'features'">
|
|
||||||
Select all of the features that your
|
|
||||||
{{ $formatProjectType(project.project_type).toLowerCase() }} makes use of.
|
|
||||||
</template>
|
|
||||||
<template v-else-if="header === 'resolutions'">
|
|
||||||
Select the resolution(s) of textures in your
|
|
||||||
{{ $formatProjectType(project.project_type).toLowerCase() }}.
|
|
||||||
</template>
|
|
||||||
<template v-else-if="header === 'performance impact'">
|
|
||||||
Select the realistic performance impact of your
|
|
||||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
|
|
||||||
{{ $formatProjectType(project.project_type).toLowerCase() }} is configurable to
|
|
||||||
different levels of performance impact.
|
|
||||||
</template>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="selectedTags.length < 1">
|
||||||
|
Select at least one category in order to feature a category.
|
||||||
|
</p>
|
||||||
<div class="category-list input-div">
|
<div class="category-list input-div">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-for="category in categoryLists[header]"
|
v-for="category in selectedTags"
|
||||||
:key="`category-${header}-${category.name}`"
|
:key="`featured-category-${category.name}`"
|
||||||
:model-value="selectedTags.includes(category)"
|
|
||||||
:description="$formatCategory(category.name)"
|
|
||||||
class="category-selector"
|
class="category-selector"
|
||||||
@update:model-value="toggleCategory(category)"
|
:model-value="featuredTags.includes(category)"
|
||||||
|
:description="$formatCategory(category.name)"
|
||||||
|
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
|
||||||
|
@update:model-value="toggleFeaturedCategory(category)"
|
||||||
>
|
>
|
||||||
<div class="category-selector__label">
|
<div class="category-selector__label">
|
||||||
<div
|
<div
|
||||||
v-if="header !== 'resolutions' && category.icon"
|
v-if="category.header !== 'resolutions' && category.icon"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="icon"
|
class="icon"
|
||||||
v-html="category.icon"
|
v-html="category.icon"
|
||||||
@@ -58,39 +96,7 @@
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="label">
|
|
||||||
<h4>
|
|
||||||
<span class="label__title"><StarIcon /> Featured tags</span>
|
|
||||||
</h4>
|
|
||||||
<span class="label__description">
|
|
||||||
You can feature up to 3 of your most relevant tags. Other tags may be promoted to featured
|
|
||||||
if you do not select all 3.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="selectedTags.length < 1">
|
|
||||||
Select at least one category in order to feature a category.
|
|
||||||
</p>
|
|
||||||
<div class="category-list input-div">
|
|
||||||
<Checkbox
|
|
||||||
v-for="category in selectedTags"
|
|
||||||
:key="`featured-category-${category.name}`"
|
|
||||||
class="category-selector"
|
|
||||||
:model-value="featuredTags.includes(category)"
|
|
||||||
:description="$formatCategory(category.name)"
|
|
||||||
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
|
|
||||||
@update:model-value="toggleFeaturedCategory(category)"
|
|
||||||
>
|
|
||||||
<div class="category-selector__label">
|
|
||||||
<div
|
|
||||||
v-if="category.header !== 'resolutions' && category.icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="icon"
|
|
||||||
v-html="category.icon"
|
|
||||||
/>
|
|
||||||
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
|
|
||||||
</div>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -541,7 +541,7 @@
|
|||||||
:custom-label="(value) => $formatCategory(value)"
|
:custom-label="(value) => $formatCategory(value)"
|
||||||
:loading="tags.loaders.length === 0"
|
:loading="tags.loaders.length === 0"
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
:searchable="false"
|
:searchable="true"
|
||||||
:show-no-results="false"
|
:show-no-results="false"
|
||||||
:close-on-select="false"
|
:close-on-select="false"
|
||||||
:clear-on-select="false"
|
:clear-on-select="false"
|
||||||
@@ -735,6 +735,11 @@ export default defineNuxtComponent({
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resetProject: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async setup(props) {
|
async setup(props) {
|
||||||
const data = useNuxtApp()
|
const data = useNuxtApp()
|
||||||
@@ -1135,13 +1140,13 @@ export default defineNuxtComponent({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEditedVersions = await this.resetProjectVersions()
|
await this.resetProjectVersions()
|
||||||
|
|
||||||
await this.$router.replace(
|
await this.$router.replace(
|
||||||
`/${this.project.project_type}/${
|
`/${this.project.project_type}/${
|
||||||
this.project.slug ? this.project.slug : this.project.id
|
this.project.slug ? this.project.slug : this.project.id
|
||||||
}/version/${encodeURI(
|
}/version/${encodeURI(
|
||||||
newEditedVersions.find((x) => x.id === this.version.id).displayUrlEnding
|
this.versions.find((x) => x.id === this.version.id).displayUrlEnding
|
||||||
)}`
|
)}`
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1316,6 +1321,7 @@ export default defineNuxtComponent({
|
|||||||
useBaseFetch(`project/${this.version.project_id}/version`),
|
useBaseFetch(`project/${this.version.project_id}/version`),
|
||||||
useBaseFetch(`project/${this.version.project_id}/version?featured=true`),
|
useBaseFetch(`project/${this.version.project_id}/version?featured=true`),
|
||||||
useBaseFetch(`project/${this.version.project_id}/dependencies`),
|
useBaseFetch(`project/${this.version.project_id}/dependencies`),
|
||||||
|
this.resetProject(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const newCreatedVersions = this.$computeVersions(versions, this.members)
|
const newCreatedVersions = this.$computeVersions(versions, this.members)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
:max-size="524288000"
|
:max-size="524288000"
|
||||||
:accept="acceptFileFromProjectType(project.project_type)"
|
:accept="acceptFileFromProjectType(project.project_type)"
|
||||||
prompt="Upload a version"
|
prompt="Upload a version"
|
||||||
class="brand-button iconified-button"
|
class="iconified-button brand-button"
|
||||||
|
:disabled="!isPermission(currentMember?.permissions, 1 << 0)"
|
||||||
@change="handleFiles"
|
@change="handleFiles"
|
||||||
>
|
>
|
||||||
<UploadIcon />
|
<UploadIcon />
|
||||||
|
|||||||
@@ -437,16 +437,14 @@ if (!collection.value) {
|
|||||||
const title = `${collection.value.name} - Collection`
|
const title = `${collection.value.name} - Collection`
|
||||||
const description = `${collection.value.description} - View the collection ${collection.value.description} by ${creator.value.username} on Modrinth`
|
const description = `${collection.value.description} - View the collection ${collection.value.description} by ${creator.value.username} on Modrinth`
|
||||||
|
|
||||||
if (!route.name.startsWith('type-id-settings')) {
|
useSeoMeta({
|
||||||
useSeoMeta({
|
title,
|
||||||
title,
|
description,
|
||||||
description,
|
ogTitle: title,
|
||||||
ogTitle: title,
|
ogDescription: collection.value.description,
|
||||||
ogDescription: collection.value.description,
|
ogImage: collection.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||||
ogImage: collection.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
robots: collection.value.status === 'listed' ? 'all' : 'noindex',
|
||||||
robots: collection.value.status === 'listed' ? 'all' : 'noindex',
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const canEdit = computed(
|
const canEdit = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -462,6 +460,8 @@ const projectTypes = computed(() => {
|
|||||||
obj[project.project_type] = true
|
obj[project.project_type] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete obj.project
|
||||||
|
|
||||||
return Object.keys(obj)
|
return Object.keys(obj)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -656,6 +656,8 @@ function showPreviewImage(files) {
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
|
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
color: var(--color-text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collection-label {
|
.collection-label {
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
<NavStackItem link="/dashboard/notifications" label="Notifications">
|
<NavStackItem link="/dashboard/notifications" label="Notifications">
|
||||||
<NotificationsIcon />
|
<NotificationsIcon />
|
||||||
</NavStackItem>
|
</NavStackItem>
|
||||||
<NavStackItem link="/dashboard/follows" label="Followed projects">
|
|
||||||
<HeartIcon />
|
|
||||||
</NavStackItem>
|
|
||||||
<NavStackItem link="/dashboard/reports" label="Active reports">
|
<NavStackItem link="/dashboard/reports" label="Active reports">
|
||||||
<ReportIcon />
|
<ReportIcon />
|
||||||
</NavStackItem>
|
</NavStackItem>
|
||||||
@@ -24,6 +21,9 @@
|
|||||||
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
||||||
<ListIcon />
|
<ListIcon />
|
||||||
</NavStackItem>
|
</NavStackItem>
|
||||||
|
<NavStackItem v-if="true" link="/dashboard/organizations" label="Organizations">
|
||||||
|
<OrganizationIcon />
|
||||||
|
</NavStackItem>
|
||||||
<NavStackItem link="/dashboard/collections" label="Collections">
|
<NavStackItem link="/dashboard/collections" label="Collections">
|
||||||
<LibraryIcon />
|
<LibraryIcon />
|
||||||
</NavStackItem>
|
</NavStackItem>
|
||||||
@@ -48,7 +48,7 @@ import CurrencyIcon from '~/assets/images/utils/currency.svg'
|
|||||||
import ListIcon from '~/assets/images/utils/list.svg'
|
import ListIcon from '~/assets/images/utils/list.svg'
|
||||||
import ReportIcon from '~/assets/images/utils/report.svg'
|
import ReportIcon from '~/assets/images/utils/report.svg'
|
||||||
import NotificationsIcon from '~/assets/images/utils/bell.svg'
|
import NotificationsIcon from '~/assets/images/utils/bell.svg'
|
||||||
import HeartIcon from '~/assets/images/utils/heart.svg'
|
import OrganizationIcon from '~/assets/images/utils/organization.svg'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ const orderedCollections = computed(() => {
|
|||||||
.collections-grid {
|
.collections-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
gap: var(--gap-md);
|
gap: var(--gap-md);
|
||||||
|
|
||||||
.collection {
|
.collection {
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="user.follows.length > 0" class="project-list display-mode--list">
|
|
||||||
<ProjectCard
|
|
||||||
v-for="project in user.follows"
|
|
||||||
:id="project.id"
|
|
||||||
:key="project.id"
|
|
||||||
:type="project.project_type"
|
|
||||||
:categories="project.categories"
|
|
||||||
:created-at="project.published"
|
|
||||||
:updated-at="project.updated"
|
|
||||||
:description="project.description"
|
|
||||||
:downloads="project.downloads ? project.downloads.toString() : '0'"
|
|
||||||
:icon-url="project.icon_url"
|
|
||||||
:name="project.title"
|
|
||||||
:client-side="project.client_side"
|
|
||||||
:server-side="project.server_side"
|
|
||||||
:color="project.color"
|
|
||||||
:show-updated-date="false"
|
|
||||||
>
|
|
||||||
<button class="iconified-button" @click="userUnfollowProject(project)">
|
|
||||||
<HeartIcon />
|
|
||||||
Unfollow
|
|
||||||
</button>
|
|
||||||
</ProjectCard>
|
|
||||||
</div>
|
|
||||||
<div v-else class="error">
|
|
||||||
<FollowIllustration class="icon" />
|
|
||||||
<br />
|
|
||||||
<span class="text"
|
|
||||||
>You don't have any followed projects. <br />
|
|
||||||
Why don't you <nuxt-link class="link" to="/mods">search</nuxt-link> for new ones?</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
|
||||||
|
|
||||||
import HeartIcon from 'assets/images/utils/heart.svg'
|
|
||||||
import FollowIllustration from 'assets/images/illustrations/follow_illustration.svg'
|
|
||||||
|
|
||||||
const user = await useUser()
|
|
||||||
if (process.client) {
|
|
||||||
await initUserFollows()
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: 'Followed review - Modrinth' })
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
198
pages/dashboard/organizations.vue
Normal file
198
pages/dashboard/organizations.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<OrganizationCreateModal ref="createOrgModal" />
|
||||||
|
<section class="universal-card">
|
||||||
|
<div class="header__row">
|
||||||
|
<h2 class="header__title">Organizations</h2>
|
||||||
|
<div class="input-group">
|
||||||
|
<button class="iconified-button brand-button" @click="openCreateOrgModal">
|
||||||
|
<PlusIcon />
|
||||||
|
Create organization
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="orgs?.length > 0">
|
||||||
|
<div class="orgs-grid">
|
||||||
|
<nuxt-link
|
||||||
|
v-for="org in orgs"
|
||||||
|
:key="org.id"
|
||||||
|
:to="`/organization/${org.slug}`"
|
||||||
|
class="universal-card button-base recessed org"
|
||||||
|
:class="{ 'is-disabled': org.members?.length === 0 }"
|
||||||
|
>
|
||||||
|
<Avatar :src="org.icon_url" :alt="org.name" class="icon" />
|
||||||
|
<div class="details">
|
||||||
|
<div class="title">
|
||||||
|
{{ org.name }}
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
{{ org.description }}
|
||||||
|
</div>
|
||||||
|
<span class="stat-bar">
|
||||||
|
<div class="stats">
|
||||||
|
<UsersIcon />
|
||||||
|
{{ org.members?.length || 0 }} member
|
||||||
|
<template v-if="org.members?.length !== 1">s</template>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else> Make an organization! </template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { PlusIcon, Avatar, UsersIcon } from 'omorphia'
|
||||||
|
|
||||||
|
import { useAuth } from '~/composables/auth.js'
|
||||||
|
import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue'
|
||||||
|
|
||||||
|
const createOrgModal = ref(null)
|
||||||
|
|
||||||
|
const auth = await useAuth()
|
||||||
|
const uid = computed(() => auth.value.user?.id || null)
|
||||||
|
|
||||||
|
const { data: orgs, error } = useAsyncData('organizations', () => {
|
||||||
|
if (!uid.value) return Promise.resolve(null)
|
||||||
|
|
||||||
|
return useBaseFetch('user/' + uid.value + '/organizations', {
|
||||||
|
apiVersion: 3,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Failed to fetch organizations',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateOrgModal = () => {
|
||||||
|
createOrgModal.value?.show()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.project-meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: var(--spacing-card-sm);
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
margin-bottom: var(--spacing-card-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.orgs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
|
@media screen and (max-width: 750px) {
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
gap: var(--gap-md);
|
||||||
|
|
||||||
|
.org {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 6rem !important;
|
||||||
|
max-width: unset !important;
|
||||||
|
max-height: unset !important;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--color-contrast);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-md);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
min-content min-content minmax(min-content, 2fr)
|
||||||
|
minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
|
||||||
|
border-radius: var(--size-rounded-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: var(--spacing-card-md);
|
||||||
|
|
||||||
|
.grid-table__row {
|
||||||
|
display: contents;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--spacing-card-sm);
|
||||||
|
|
||||||
|
// Left edge of table
|
||||||
|
&:first-child {
|
||||||
|
padding-left: var(--spacing-card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right edge of table
|
||||||
|
&:last-child {
|
||||||
|
padding-right: var(--spacing-card-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2n + 1) > div {
|
||||||
|
background-color: var(--color-table-alternate-row);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.grid-table__header > div {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
padding-top: var(--spacing-card-bg);
|
||||||
|
padding-bottom: var(--spacing-card-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from 'omorphia'
|
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from 'omorphia'
|
||||||
|
|
||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
const minWithdraw = ref(0.1)
|
const minWithdraw = ref(0.01)
|
||||||
|
|
||||||
async function updateVenmo() {
|
async function updateVenmo() {
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ async function cancelPayout(id) {
|
|||||||
await refresh()
|
await refresh()
|
||||||
await useAuth(auth.value.token)
|
await useAuth(auth.value.token)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
|
||||||
data.$notify({
|
data.$notify({
|
||||||
group: 'main',
|
group: 'main',
|
||||||
title: 'An error occurred',
|
title: 'An error occurred',
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ const selectedMethod = computed(() =>
|
|||||||
const parsedAmount = computed(() => {
|
const parsedAmount = computed(() => {
|
||||||
const regex = /^\$?(\d*(\.\d{2})?)$/gm
|
const regex = /^\$?(\d*(\.\d{2})?)$/gm
|
||||||
const matches = regex.exec(amount.value)
|
const matches = regex.exec(amount.value)
|
||||||
console.log(matches)
|
|
||||||
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
|
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
|
||||||
})
|
})
|
||||||
const fees = computed(() => {
|
const fees = computed(() => {
|
||||||
@@ -251,7 +250,6 @@ const agreedTerms = ref(false)
|
|||||||
|
|
||||||
watch(country, async () => {
|
watch(country, async () => {
|
||||||
await refreshPayoutMethods()
|
await refreshPayoutMethods()
|
||||||
console.log(payoutMethods.value)
|
|
||||||
if (payoutMethods.value && payoutMethods.value[0]) {
|
if (payoutMethods.value && payoutMethods.value[0]) {
|
||||||
selectedMethodId.value = payoutMethods.value[0].id
|
selectedMethodId.value = payoutMethods.value[0].id
|
||||||
}
|
}
|
||||||
@@ -285,7 +283,6 @@ async function withdraw() {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
|
||||||
data.$notify({
|
data.$notify({
|
||||||
group: 'main',
|
group: 'main',
|
||||||
title: 'An error occurred',
|
title: 'An error occurred',
|
||||||
|
|||||||
538
pages/organization/[id].vue
Normal file
538
pages/organization/[id].vue
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
<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>
|
||||||
|
<span>
|
||||||
|
{{ $formatNumber(acceptedMembers?.length || 0) }}
|
||||||
|
</span>
|
||||||
|
member
|
||||||
|
<span v-if="acceptedMembers?.length !== 1">s</span>
|
||||||
|
</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<span v-if="acceptedMembers?.length !== 1">s</span>
|
||||||
|
</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 }}</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
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.featured - a.featured)
|
||||||
|
.map((x) => x.url)[0]
|
||||||
|
"
|
||||||
|
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 { 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.role === 'Owner')
|
||||||
|
const rest = acceptedMembers.filter((x) => x.role !== '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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
1
pages/organization/[id]/[projectType].vue
Normal file
1
pages/organization/[id]/[projectType].vue
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<template><div /></template>
|
||||||
27
pages/organization/[id]/settings/analytics.vue
Normal file
27
pages/organization/[id]/settings/analytics.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="universal-card">
|
||||||
|
<h2>Analytics</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This page shows you the analytics for your organization's projects. You can see the number
|
||||||
|
of downloads, page views and revenue earned for all of your projects, as well as the total
|
||||||
|
downloads and page views for each project by country.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChartDisplay :projects="projects" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
|
||||||
|
|
||||||
|
const { projects } = inject('organizationContext')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.markdown-body {
|
||||||
|
margin-bottom: var(--gap-md);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
218
pages/organization/[id]/settings/index.vue
Normal file
218
pages/organization/[id]/settings/index.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Button, FileInput, TrashIcon, Avatar, UploadIcon, SaveIcon, ConfirmModal } from 'omorphia'
|
||||||
|
|
||||||
|
const {
|
||||||
|
organization,
|
||||||
|
refresh: refreshOrganization,
|
||||||
|
hasPermission,
|
||||||
|
deleteIcon,
|
||||||
|
patchIcon,
|
||||||
|
patchOrganization,
|
||||||
|
} = inject('organizationContext')
|
||||||
|
|
||||||
|
const icon = ref(null)
|
||||||
|
const deletedIcon = ref(false)
|
||||||
|
const previewImage = ref(null)
|
||||||
|
|
||||||
|
const name = ref(organization.value.name)
|
||||||
|
const slug = ref(organization.value.slug)
|
||||||
|
|
||||||
|
const summary = ref(organization.value.description)
|
||||||
|
|
||||||
|
const patchData = computed(() => {
|
||||||
|
const data = {}
|
||||||
|
if (name.value !== organization.value.name) {
|
||||||
|
data.name = name.value
|
||||||
|
}
|
||||||
|
if (slug.value !== organization.value.slug) {
|
||||||
|
data.slug = slug.value
|
||||||
|
}
|
||||||
|
if (summary.value !== organization.value.description) {
|
||||||
|
data.description = summary.value
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const markIconForDeletion = () => {
|
||||||
|
deletedIcon.value = true
|
||||||
|
icon.value = null
|
||||||
|
previewImage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPreviewImage = (files) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
icon.value = files[0]
|
||||||
|
deletedIcon.value = false
|
||||||
|
|
||||||
|
reader.readAsDataURL(icon.value)
|
||||||
|
reader.onload = (event) => {
|
||||||
|
previewImage.value = event.target.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = useRouteId()
|
||||||
|
|
||||||
|
const onSaveChanges = useClientTry(async () => {
|
||||||
|
if (hasChanges.value) {
|
||||||
|
await patchOrganization(orgId, patchData.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedIcon.value) {
|
||||||
|
await deleteIcon()
|
||||||
|
deletedIcon.value = false
|
||||||
|
} else if (icon.value) {
|
||||||
|
await patchIcon(icon.value)
|
||||||
|
icon.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshOrganization()
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Organization updated',
|
||||||
|
text: 'Your organization has been updated.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onDeleteOrganization = useClientTry(async () => {
|
||||||
|
await useBaseFetch(`organization/${orgId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
apiVersion: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Organization deleted',
|
||||||
|
text: 'Your organization has been deleted.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
await navigateTo('/dashboard/organizations')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="normal-page__content">
|
||||||
|
<ConfirmModal
|
||||||
|
ref="modal_deletion"
|
||||||
|
:title="`Are you sure you want to delete ${organization.name}?`"
|
||||||
|
description="This will delete this organization forever (like *forever* ever)."
|
||||||
|
:has-to-type="true"
|
||||||
|
proceed-label="Delete"
|
||||||
|
:confirmation-text="organization.name"
|
||||||
|
@proceed="onDeleteOrganization"
|
||||||
|
/>
|
||||||
|
<div class="universal-card">
|
||||||
|
<div class="label">
|
||||||
|
<h3>
|
||||||
|
<span class="label__title size-card-header">Organization information</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<label for="project-icon">
|
||||||
|
<span class="label__title">Icon</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<Avatar
|
||||||
|
:src="deletedIcon ? null : previewImage ? previewImage : organization.icon_url"
|
||||||
|
:alt="organization.name"
|
||||||
|
size="md"
|
||||||
|
class="project__icon"
|
||||||
|
/>
|
||||||
|
<div class="input-stack">
|
||||||
|
<FileInput
|
||||||
|
id="project-icon"
|
||||||
|
:max-size="262144"
|
||||||
|
:show-icon="true"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
class="btn"
|
||||||
|
prompt="Upload icon"
|
||||||
|
:disabled="!hasPermission"
|
||||||
|
@change="showPreviewImage"
|
||||||
|
>
|
||||||
|
<UploadIcon />
|
||||||
|
</FileInput>
|
||||||
|
<Button
|
||||||
|
v-if="!deletedIcon && (previewImage || organization.icon_url)"
|
||||||
|
:disabled="!hasPermission"
|
||||||
|
@click="markIconForDeletion"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
Remove icon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="project-name">
|
||||||
|
<span class="label__title">Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="project-name"
|
||||||
|
v-model="name"
|
||||||
|
maxlength="2048"
|
||||||
|
type="text"
|
||||||
|
:disabled="!hasPermission"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label for="project-slug">
|
||||||
|
<span class="label__title">URL</span>
|
||||||
|
</label>
|
||||||
|
<div class="text-input-wrapper">
|
||||||
|
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
|
||||||
|
<input
|
||||||
|
id="project-slug"
|
||||||
|
v-model="slug"
|
||||||
|
type="text"
|
||||||
|
maxlength="64"
|
||||||
|
autocomplete="off"
|
||||||
|
:disabled="!hasPermission"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="project-summary">
|
||||||
|
<span class="label__title">Summary</span>
|
||||||
|
</label>
|
||||||
|
<div class="textarea-wrapper summary-input">
|
||||||
|
<textarea
|
||||||
|
id="project-summary"
|
||||||
|
v-model="summary"
|
||||||
|
maxlength="256"
|
||||||
|
:disabled="!hasPermission"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<Button color="primary" :disabled="!hasChanges" @click="onSaveChanges">
|
||||||
|
<SaveIcon />
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="universal-card">
|
||||||
|
<div class="label">
|
||||||
|
<h3>
|
||||||
|
<span class="label__title size-card-header">Delete organization</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Deleting your organization will transfer all of its projects to the organization owner. This
|
||||||
|
action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Button color="danger" @click="() => $refs.modal_deletion.show()">
|
||||||
|
<TrashIcon />
|
||||||
|
Delete organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.summary-input {
|
||||||
|
min-height: 8rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
428
pages/organization/[id]/settings/members.vue
Normal file
428
pages/organization/[id]/settings/members.vue
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
<template>
|
||||||
|
<div class="normal-page__content">
|
||||||
|
<div class="universal-card">
|
||||||
|
<div class="label">
|
||||||
|
<h3>
|
||||||
|
<span class="label__title size-card-header">Manage members</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span class="label">
|
||||||
|
<span class="label__title">Invite a member</span>
|
||||||
|
<span class="label__description">
|
||||||
|
Enter the Modrinth username of the person you'd like to invite to be a member of this
|
||||||
|
organization.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="currentUsername"
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.MANAGE_INVITES
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@keypress.enter="() => onInviteTeamMember(organization.team_id, currentUsername)"
|
||||||
|
/>
|
||||||
|
<label for="username" class="hidden">Username</label>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.MANAGE_INVITES
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="() => onInviteTeamMember(organization.team_id, currentUsername)"
|
||||||
|
>
|
||||||
|
<UserPlusIcon />
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="adjacent-input">
|
||||||
|
<span class="label">
|
||||||
|
<span class="label__title">Leave organization</span>
|
||||||
|
<span class="label__description">
|
||||||
|
Remove yourself as a member of this organization.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Button color="danger" :disabled="currentMember.role === 'Owner'" @click="leaveProject()">
|
||||||
|
<UserRemoveIcon />
|
||||||
|
Leave organization
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(member, index) in allTeamMembers"
|
||||||
|
:key="member.user.id"
|
||||||
|
class="member universal-card"
|
||||||
|
:class="{ open: openTeamMembers.includes(member.user.id) }"
|
||||||
|
>
|
||||||
|
<div class="member-header">
|
||||||
|
<div class="info">
|
||||||
|
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="sm" circle />
|
||||||
|
<div class="text">
|
||||||
|
<nuxt-link :to="'/user/' + member.user.username" class="name">
|
||||||
|
<p>{{ member.user.username }}</p>
|
||||||
|
</nuxt-link>
|
||||||
|
<p>{{ member.role }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="side-buttons">
|
||||||
|
<Badge v-if="member.accepted" type="accepted" />
|
||||||
|
<Badge v-else type="pending" />
|
||||||
|
<Button
|
||||||
|
icon-only
|
||||||
|
transparent
|
||||||
|
class="dropdown-icon"
|
||||||
|
@click="
|
||||||
|
openTeamMembers.indexOf(member.user.id) === -1
|
||||||
|
? openTeamMembers.push(member.user.id)
|
||||||
|
: (openTeamMembers = openTeamMembers.filter((it) => it !== member.user.id))
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DropdownIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div v-if="member.oldRole !== 'Owner'" class="adjacent-input">
|
||||||
|
<label :for="`member-${member.user.id}-role`">
|
||||||
|
<span class="label__title">Role</span>
|
||||||
|
<span class="label__description">
|
||||||
|
The title of the role that this member plays for this organization.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:id="`member-${member.user.id}-role`"
|
||||||
|
v-model="member.role"
|
||||||
|
type="text"
|
||||||
|
:class="{ 'known-error': member.role === 'Owner' }"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.EDIT_MEMBER
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="adjacent-input">
|
||||||
|
<label :for="`member-${member.user.id}-monetization-weight`">
|
||||||
|
<span class="label__title">Monetization weight</span>
|
||||||
|
<span class="label__description">
|
||||||
|
Relative to all other members' monetization weights, this determines what portion of
|
||||||
|
the organization projects' revenue goes to this member.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:id="`member-${member.user.id}-monetization-weight`"
|
||||||
|
v-model="member.payouts_split"
|
||||||
|
type="number"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.EDIT_MEMBER
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="member.role === 'Owner' && member.oldRole !== 'Owner'" class="known-errors">
|
||||||
|
A organization can only have one 'Owner'. Use the 'Transfer ownership' button below if you
|
||||||
|
no longer wish to be owner.
|
||||||
|
</p>
|
||||||
|
<template v-if="member.oldRole !== 'Owner'">
|
||||||
|
<span class="label">
|
||||||
|
<span class="label__title">Project permissions</span>
|
||||||
|
</span>
|
||||||
|
<div class="permissions">
|
||||||
|
<Checkbox
|
||||||
|
v-for="[label, permission] in Object.entries(projectPermissions)"
|
||||||
|
:key="permission"
|
||||||
|
:model-value="isPermission(member.permissions, permission)"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.EDIT_MEMBER_DEFAULT_PERMISSIONS
|
||||||
|
) || !isPermission(currentMember.permissions, permission)
|
||||||
|
"
|
||||||
|
:label="permToLabel(label)"
|
||||||
|
@update:model-value="allTeamMembers[index].permissions ^= permission"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="member.oldRole !== 'Owner'">
|
||||||
|
<span class="label">
|
||||||
|
<span class="label__title">Organization permissions</span>
|
||||||
|
</span>
|
||||||
|
<div class="permissions">
|
||||||
|
<Checkbox
|
||||||
|
v-for="[label, permission] in Object.entries(organizationPermissions)"
|
||||||
|
:key="permission"
|
||||||
|
:model-value="isPermission(member.organization_permissions, permission)"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.EDIT_MEMBER
|
||||||
|
) || !isPermission(currentMember.organization_permissions, permission)
|
||||||
|
"
|
||||||
|
:label="permToLabel(label)"
|
||||||
|
@update:model-value="allTeamMembers[index].organization_permissions ^= permission"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="input-group">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.EDIT_MEMBER
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="onUpdateTeamMember(organization.team_id, member)"
|
||||||
|
>
|
||||||
|
<SaveIcon />
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="member.oldRole !== 'Owner'"
|
||||||
|
color="danger"
|
||||||
|
:disabled="
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.EDIT_MEMBER
|
||||||
|
) &&
|
||||||
|
!isPermission(
|
||||||
|
currentMember.organization_permissions,
|
||||||
|
organizationPermissions.REMOVE_MEMBER
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="onRemoveMember(organization.team_id, member)"
|
||||||
|
>
|
||||||
|
<UserRemoveIcon />
|
||||||
|
Remove member
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="member.oldRole !== 'Owner' && currentMember.role === 'Owner' && member.accepted"
|
||||||
|
@click="() => onTransferOwnership(organization.team_id, member.user.id)"
|
||||||
|
>
|
||||||
|
<TransferIcon />
|
||||||
|
Transfer ownership
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Checkbox,
|
||||||
|
SaveIcon,
|
||||||
|
Badge,
|
||||||
|
TransferIcon,
|
||||||
|
UserPlusIcon,
|
||||||
|
UserXIcon as UserRemoveIcon,
|
||||||
|
DropdownIcon,
|
||||||
|
Button,
|
||||||
|
} from 'omorphia'
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { removeTeamMember } from '~/helpers/teams.js'
|
||||||
|
import { isPermission } from '~/utils/permissions.ts'
|
||||||
|
|
||||||
|
const { organization, refresh: refreshOrganization, currentMember } = inject('organizationContext')
|
||||||
|
|
||||||
|
const auth = await useAuth()
|
||||||
|
|
||||||
|
const currentUsername = ref('')
|
||||||
|
const openTeamMembers = ref([])
|
||||||
|
|
||||||
|
const processMembers = (members) => {
|
||||||
|
return members
|
||||||
|
.map((x) => ({ ...x, oldRole: x.role }))
|
||||||
|
.sort((a, b) => a.user.username.localeCompare(b.user.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTeamMembers = ref(processMembers(organization.value.members))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => organization.value,
|
||||||
|
() => {
|
||||||
|
allTeamMembers.value = processMembers(organization.value.members)
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const projectPermissions = {
|
||||||
|
UPLOAD_VERSION: 1 << 0,
|
||||||
|
DELETE_VERSION: 1 << 1,
|
||||||
|
EDIT_DETAILS: 1 << 2,
|
||||||
|
EDIT_BODY: 1 << 3,
|
||||||
|
MANAGE_INVITES: 1 << 4,
|
||||||
|
REMOVE_MEMBER: 1 << 5,
|
||||||
|
EDIT_MEMBER: 1 << 6,
|
||||||
|
DELETE_PROJECT: 1 << 7,
|
||||||
|
VIEW_ANALYTICS: 1 << 8,
|
||||||
|
VIEW_PAYOUTS: 1 << 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationPermissions = {
|
||||||
|
EDIT_DETAILS: 1 << 0,
|
||||||
|
MANAGE_INVITES: 1 << 1,
|
||||||
|
REMOVE_MEMBER: 1 << 2,
|
||||||
|
EDIT_MEMBER: 1 << 3,
|
||||||
|
ADD_PROJECT: 1 << 4,
|
||||||
|
REMOVE_PROJECT: 1 << 5,
|
||||||
|
DELETE_ORGANIZATION: 1 << 6,
|
||||||
|
EDIT_MEMBER_DEFAULT_PERMISSIONS: 1 << 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
const permToLabel = (key) => {
|
||||||
|
const o = key.split('_').join(' ')
|
||||||
|
return o.charAt(0).toUpperCase() + o.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveProject = async () => {
|
||||||
|
await removeTeamMember(organization.value.team_id, auth.user.id)
|
||||||
|
await navigateTo(`/organization/${organization.value.slug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInviteTeamMember = useClientTry(async (teamId, username) => {
|
||||||
|
const user = await useBaseFetch(`user/${username}`)
|
||||||
|
const data = {
|
||||||
|
user_id: user.id.trim(),
|
||||||
|
}
|
||||||
|
await useBaseFetch(`team/${teamId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
await refreshOrganization()
|
||||||
|
currentUsername.value = ''
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Member invited',
|
||||||
|
text: `${user.username} has been invited to the organization.`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onRemoveMember = useClientTry(async (teamId, member) => {
|
||||||
|
await removeTeamMember(teamId, member.user.id)
|
||||||
|
await refreshOrganization()
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Member removed',
|
||||||
|
text: `${member.user.username} has been removed from the organization.`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onUpdateTeamMember = useClientTry(async (teamId, member) => {
|
||||||
|
const data =
|
||||||
|
member.oldRole !== 'Owner'
|
||||||
|
? {
|
||||||
|
permissions: member.permissions,
|
||||||
|
organization_permissions: member.organization_permissions,
|
||||||
|
role: member.role,
|
||||||
|
payouts_split: member.payouts_split,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
payouts_split: member.payouts_split,
|
||||||
|
}
|
||||||
|
await useBaseFetch(`team/${teamId}/members/${member.user.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
await refreshOrganization()
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Member updated',
|
||||||
|
text: `${member.user.username} has been updated.`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onTransferOwnership = useClientTry(async (teamId, uid) => {
|
||||||
|
const data = {
|
||||||
|
user_id: uid,
|
||||||
|
}
|
||||||
|
await useBaseFetch(`team/${teamId}/owner`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
await refreshOrganization()
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Ownership transferred',
|
||||||
|
text: `The ownership of ${organization.value.name} has been successfully transferred.`,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.member {
|
||||||
|
.member-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
.text {
|
||||||
|
margin: auto 0 auto 0.5rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
.name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.side-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.dropdown-icon {
|
||||||
|
margin-left: 1rem;
|
||||||
|
svg {
|
||||||
|
transition: 150ms ease transform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: var(--gap-md);
|
||||||
|
.main-info {
|
||||||
|
margin-bottom: var(--gap-lg);
|
||||||
|
}
|
||||||
|
.permissions {
|
||||||
|
margin-bottom: var(--gap-md);
|
||||||
|
max-width: 45rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||||
|
grid-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.open {
|
||||||
|
.member-header {
|
||||||
|
.dropdown-icon svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:deep(.checkbox-outer) {
|
||||||
|
button.checkbox {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
668
pages/organization/[id]/settings/projects.vue
Normal file
668
pages/organization/[id]/settings/projects.vue
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
<template>
|
||||||
|
<div class="normal-page__content">
|
||||||
|
<Modal ref="editLinksModal" header="Edit links">
|
||||||
|
<div class="universal-modal links-modal">
|
||||||
|
<p>
|
||||||
|
Any links you specify below will be overwritten on each of the selected projects. Any you
|
||||||
|
leave blank will be ignored. You can clear a link from all selected projects using the
|
||||||
|
trash can button.
|
||||||
|
</p>
|
||||||
|
<section class="links">
|
||||||
|
<label
|
||||||
|
for="issue-tracker-input"
|
||||||
|
title="A place for users to report bugs, issues, and concerns about your project."
|
||||||
|
>
|
||||||
|
<span class="label__title">Issue tracker</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group shrink-first">
|
||||||
|
<input
|
||||||
|
id="issue-tracker-input"
|
||||||
|
v-model="editLinks.issues.val"
|
||||||
|
:disabled="editLinks.issues.clear"
|
||||||
|
type="url"
|
||||||
|
:placeholder="
|
||||||
|
editLinks.issues.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
||||||
|
"
|
||||||
|
maxlength="2048"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-tooltip="'Clear link'"
|
||||||
|
aria-label="Clear link"
|
||||||
|
class="square-button label-button"
|
||||||
|
:data-active="editLinks.issues.clear"
|
||||||
|
icon-only
|
||||||
|
@click="editLinks.issues.clear = !editLinks.issues.clear"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
for="source-code-input"
|
||||||
|
title="A page/repository containing the source code for your project"
|
||||||
|
>
|
||||||
|
<span class="label__title">Source code</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group shrink-first">
|
||||||
|
<input
|
||||||
|
id="source-code-input"
|
||||||
|
v-model="editLinks.source.val"
|
||||||
|
:disabled="editLinks.source.clear"
|
||||||
|
type="url"
|
||||||
|
maxlength="2048"
|
||||||
|
:placeholder="
|
||||||
|
editLinks.source.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-tooltip="'Clear link'"
|
||||||
|
aria-label="Clear link"
|
||||||
|
:data-active="editLinks.source.clear"
|
||||||
|
icon-only
|
||||||
|
@click="editLinks.source.clear = !editLinks.source.clear"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
for="wiki-page-input"
|
||||||
|
title="A page containing information, documentation, and help for the project."
|
||||||
|
>
|
||||||
|
<span class="label__title">Wiki page</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group shrink-first">
|
||||||
|
<input
|
||||||
|
id="wiki-page-input"
|
||||||
|
v-model="editLinks.wiki.val"
|
||||||
|
:disabled="editLinks.wiki.clear"
|
||||||
|
type="url"
|
||||||
|
maxlength="2048"
|
||||||
|
:placeholder="
|
||||||
|
editLinks.wiki.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-tooltip="'Clear link'"
|
||||||
|
aria-label="Clear link"
|
||||||
|
:data-active="editLinks.wiki.clear"
|
||||||
|
icon-only
|
||||||
|
@click="editLinks.wiki.clear = !editLinks.wiki.clear"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label for="discord-invite-input" title="An invitation link to your Discord server.">
|
||||||
|
<span class="label__title">Discord invite</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group shrink-first">
|
||||||
|
<input
|
||||||
|
id="discord-invite-input"
|
||||||
|
v-model="editLinks.discord.val"
|
||||||
|
:disabled="editLinks.discord.clear"
|
||||||
|
type="url"
|
||||||
|
maxlength="2048"
|
||||||
|
:placeholder="
|
||||||
|
editLinks.discord.clear
|
||||||
|
? 'Existing link will be cleared'
|
||||||
|
: 'Enter a valid Discord invite URL'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-tooltip="'Clear link'"
|
||||||
|
aria-label="Clear link"
|
||||||
|
:data-active="editLinks.discord.clear"
|
||||||
|
icon-only
|
||||||
|
@click="editLinks.discord.clear = !editLinks.discord.clear"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p>
|
||||||
|
Changes will be applied to
|
||||||
|
<strong>{{ selectedProjects.length }}</strong> project{{
|
||||||
|
selectedProjects.length > 1 ? 's' : ''
|
||||||
|
}}.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="project in selectedProjects.slice(
|
||||||
|
0,
|
||||||
|
editLinks.showAffected ? selectedProjects.length : 3
|
||||||
|
)"
|
||||||
|
:key="project.id"
|
||||||
|
>
|
||||||
|
{{ project.name }}
|
||||||
|
</li>
|
||||||
|
<li v-if="!editLinks.showAffected && selectedProjects.length > 3">
|
||||||
|
<strong>and {{ selectedProjects.length - 3 }} more...</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Checkbox
|
||||||
|
v-if="selectedProjects.length > 3"
|
||||||
|
v-model="editLinks.showAffected"
|
||||||
|
:label="editLinks.showAffected ? 'Less' : 'More'"
|
||||||
|
description="Show all loaders"
|
||||||
|
:border="false"
|
||||||
|
:collapsing-toggle-style="true"
|
||||||
|
/>
|
||||||
|
<div class="push-right input-group">
|
||||||
|
<Button @click="$refs.editLinksModal.hide()">
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" @click="onBulkEditLinks">
|
||||||
|
<SaveIcon />
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
|
||||||
|
<div class="universal-card">
|
||||||
|
<h2>Projects</h2>
|
||||||
|
<div class="input-group">
|
||||||
|
<Button color="primary" @click="$refs.modal_creation.show()">
|
||||||
|
<PlusIcon />
|
||||||
|
Create a project
|
||||||
|
</Button>
|
||||||
|
<OrganizationProjectTransferModal
|
||||||
|
:projects="userProjects || []"
|
||||||
|
@submit="onProjectTransferSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="sortedProjects.length < 1">
|
||||||
|
You don't have any projects yet. Click the green button above to begin.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p>You can edit multiple projects at once by selecting them below.</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<Button :disabled="selectedProjects.length === 0" @click="$refs.editLinksModal.show()">
|
||||||
|
<EditIcon />
|
||||||
|
Edit links
|
||||||
|
</Button>
|
||||||
|
<div class="push-right">
|
||||||
|
<div class="labeled-control-row">
|
||||||
|
Sort by
|
||||||
|
<Multiselect
|
||||||
|
v-model="sortBy"
|
||||||
|
:searchable="false"
|
||||||
|
class="small-select"
|
||||||
|
:options="['Name', 'Status', 'Type']"
|
||||||
|
:close-on-select="true"
|
||||||
|
:show-labels="false"
|
||||||
|
:allow-empty="false"
|
||||||
|
@update:model-value="
|
||||||
|
sortedProjects = updateSort(sortedProjects, sortBy, descending)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-tooltip="descending ? 'Descending' : 'Ascending'"
|
||||||
|
class="square-button"
|
||||||
|
icon-only
|
||||||
|
@click="updateDescending()"
|
||||||
|
>
|
||||||
|
<SortDescendingIcon v-if="descending" />
|
||||||
|
<SortAscendingIcon v-else />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table">
|
||||||
|
<div class="table-row table-head">
|
||||||
|
<div class="table-cell check-cell">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="selectedProjects === sortedProjects"
|
||||||
|
@update:model-value="
|
||||||
|
selectedProjects === sortedProjects
|
||||||
|
? (selectedProjects = [])
|
||||||
|
: (selectedProjects = sortedProjects)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="table-cell">Icon</div>
|
||||||
|
<div class="table-cell">Name</div>
|
||||||
|
<div class="table-cell">ID</div>
|
||||||
|
<div class="table-cell">Type</div>
|
||||||
|
<div class="table-cell">Status</div>
|
||||||
|
<div class="table-cell" />
|
||||||
|
</div>
|
||||||
|
<div v-for="project in sortedProjects" :key="`project-${project.id}`" class="table-row">
|
||||||
|
<div class="table-cell check-cell">
|
||||||
|
<Checkbox
|
||||||
|
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
|
||||||
|
:model-value="selectedProjects.includes(project)"
|
||||||
|
@update:model-value="
|
||||||
|
selectedProjects.includes(project)
|
||||||
|
? (selectedProjects = selectedProjects.filter((it) => it !== project))
|
||||||
|
: selectedProjects.push(project)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="table-cell">
|
||||||
|
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
|
||||||
|
<Avatar
|
||||||
|
:src="project.icon_url"
|
||||||
|
aria-hidden="true"
|
||||||
|
:alt="'Icon for ' + project.name"
|
||||||
|
no-shadow
|
||||||
|
/>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<span class="project-title">
|
||||||
|
<IssuesIcon
|
||||||
|
v-if="project.moderator_message"
|
||||||
|
aria-label="Project has a message from the moderators. View the project to see more."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<nuxt-link
|
||||||
|
class="hover-link wrap-as-needed"
|
||||||
|
:to="`/project/${project.slug ? project.slug : project.id}`"
|
||||||
|
>
|
||||||
|
{{ project.name }}
|
||||||
|
</nuxt-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<CopyCode :text="project.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<BoxIcon />
|
||||||
|
<span>{{
|
||||||
|
$formatProjectType(
|
||||||
|
$getProjectTypeForDisplay(project.project_types[0] ?? 'project', project.loaders)
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<Badge v-if="project.status" :type="project.status" class="status" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell">
|
||||||
|
<nuxt-link
|
||||||
|
class="btn icon-only"
|
||||||
|
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Multiselect } from 'vue-multiselect'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Checkbox,
|
||||||
|
BoxIcon,
|
||||||
|
Modal,
|
||||||
|
Avatar,
|
||||||
|
CopyCode,
|
||||||
|
SettingsIcon,
|
||||||
|
TrashIcon,
|
||||||
|
IssuesIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XIcon,
|
||||||
|
EditIcon,
|
||||||
|
SaveIcon,
|
||||||
|
Button,
|
||||||
|
SortAscendingIcon,
|
||||||
|
SortDescendingIcon,
|
||||||
|
} from 'omorphia'
|
||||||
|
|
||||||
|
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
||||||
|
import OrganizationProjectTransferModal from '~/components/ui/OrganizationProjectTransferModal.vue'
|
||||||
|
|
||||||
|
const { organization, projects, refresh } = inject('organizationContext')
|
||||||
|
|
||||||
|
const auth = await useAuth()
|
||||||
|
|
||||||
|
const { data: userProjects } = await useAsyncData(
|
||||||
|
`user/${auth.value.user.id}/projects`,
|
||||||
|
() => useBaseFetch(`user/${auth.value.user.id}/projects`),
|
||||||
|
{
|
||||||
|
watch: [auth],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const onProjectTransferSubmit = async (projects) => {
|
||||||
|
try {
|
||||||
|
for (const project of projects) {
|
||||||
|
await useBaseFetch(`organization/${organization.value.id}/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: project.id,
|
||||||
|
}),
|
||||||
|
apiVersion: 3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh()
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Success',
|
||||||
|
text: 'Transferred selected projects to organization.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'An error occurred',
|
||||||
|
text: err?.data?.description || err?.message || err || 'Unknown error',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_DETAILS = 1 << 2
|
||||||
|
|
||||||
|
const updateSort = (inputProjects, sort, descending) => {
|
||||||
|
let sortedArray = inputProjects
|
||||||
|
switch (sort) {
|
||||||
|
case 'Name':
|
||||||
|
sortedArray = inputProjects.slice().sort((a, b) => {
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'Status':
|
||||||
|
sortedArray = inputProjects.slice().sort((a, b) => {
|
||||||
|
if (a.status < b.status) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (a.status > b.status) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'Type':
|
||||||
|
sortedArray = inputProjects.slice().sort((a, b) => {
|
||||||
|
if (a.project_type < b.project_type) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (a.project_type > b.project_type) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descending) {
|
||||||
|
sortedArray = sortedArray.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedArray
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedProjects = ref(updateSort(projects.value, 'Name'))
|
||||||
|
const selectedProjects = ref([])
|
||||||
|
const sortBy = ref('Name')
|
||||||
|
const descending = ref(false)
|
||||||
|
const editLinksModal = ref(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => projects.value,
|
||||||
|
(newVal) => {
|
||||||
|
sortedProjects.value = updateSort(newVal, sortBy.value, descending.value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emptyLinksData = {
|
||||||
|
showAffected: false,
|
||||||
|
source: {
|
||||||
|
val: '',
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
val: '',
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
wiki: {
|
||||||
|
val: '',
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
issues: {
|
||||||
|
val: '',
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const editLinks = ref(emptyLinksData)
|
||||||
|
|
||||||
|
const updateDescending = () => {
|
||||||
|
descending.value = !descending.value
|
||||||
|
sortedProjects.value = updateSort(sortedProjects.value, sortBy.value, descending.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBulkEditLinks = useClientTry(async () => {
|
||||||
|
const linkData = editLinks.value
|
||||||
|
|
||||||
|
const baseData = {}
|
||||||
|
|
||||||
|
if (linkData.issues.clear) {
|
||||||
|
baseData.issues_url = null
|
||||||
|
} else if (linkData.issues.val.trim().length > 0) {
|
||||||
|
baseData.issues_url = linkData.issues.val.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkData.source.clear) {
|
||||||
|
baseData.source_url = null
|
||||||
|
} else if (linkData.source.val.trim().length > 0) {
|
||||||
|
baseData.source_url = linkData.source.val.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkData.wiki.clear) {
|
||||||
|
baseData.wiki_url = null
|
||||||
|
} else if (linkData.wiki.val.trim().length > 0) {
|
||||||
|
baseData.wiki_url = linkData.wiki.val.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkData.discord.clear) {
|
||||||
|
baseData.discord_url = null
|
||||||
|
} else if (linkData.discord.val.trim().length > 0) {
|
||||||
|
baseData.discord_url = linkData.discord.val.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
await useBaseFetch(`projects?ids=${JSON.stringify(selectedProjects.value.map((x) => x.id))}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(baseData),
|
||||||
|
})
|
||||||
|
|
||||||
|
editLinksModal.value.hide()
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
group: 'main',
|
||||||
|
title: 'Success',
|
||||||
|
text: "Bulk edited selected project's links.",
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
selectedProjects.value = []
|
||||||
|
editLinks.value = emptyLinksData
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.table {
|
||||||
|
display: grid;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: var(--gap-md);
|
||||||
|
border: 1px solid var(--color-button-bg);
|
||||||
|
background-color: var(--color-raised-bg);
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 1fr 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-xs);
|
||||||
|
padding: var(--gap-md);
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-cell {
|
||||||
|
padding-left: var(--gap-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 750px) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template: 'checkbox icon name type settings' 'checkbox icon id status settings';
|
||||||
|
grid-template-columns:
|
||||||
|
min-content min-content minmax(min-content, 2fr)
|
||||||
|
minmax(min-content, 1fr) min-content;
|
||||||
|
|
||||||
|
:nth-child(1) {
|
||||||
|
grid-area: checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(2) {
|
||||||
|
grid-area: icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(3) {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(4) {
|
||||||
|
grid-area: id;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(5) {
|
||||||
|
grid-area: type;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(6) {
|
||||||
|
grid-area: status;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:nth-child(7) {
|
||||||
|
grid-area: settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
grid-template: 'checkbox settings';
|
||||||
|
grid-template-columns: min-content minmax(min-content, 1fr);
|
||||||
|
|
||||||
|
:nth-child(2),
|
||||||
|
:nth-child(3),
|
||||||
|
:nth-child(4),
|
||||||
|
:nth-child(5),
|
||||||
|
:nth-child(6) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 560px) {
|
||||||
|
.table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
|
||||||
|
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
|
||||||
|
|
||||||
|
:nth-child(5) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
grid-template: 'checkbox settings';
|
||||||
|
grid-template-columns: min-content minmax(min-content, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--spacing-card-xs);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-special-orange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: var(--spacing-card-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labeled-control-row {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-select {
|
||||||
|
width: fit-content;
|
||||||
|
width: -moz-fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-button[data-active='true'] {
|
||||||
|
--background-color: var(--color-special-red);
|
||||||
|
--text-color: var(--color-brand-inverted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-modal {
|
||||||
|
.links {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-card-sm);
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 530px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
.input-group {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0 0 var(--spacing-card-sm) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-block: var(--gap-sm) var(--gap-lg);
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.checkbox-outer) {
|
||||||
|
button.checkbox {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -154,6 +154,23 @@
|
|||||||
</IntlFormatted>
|
</IntlFormatted>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="organizations.length > 0">
|
||||||
|
<hr class="card-divider" />
|
||||||
|
<div class="stats-block__item">
|
||||||
|
<IntlFormatted :message-id="messages.profileOrganizations" />
|
||||||
|
<div class="organizations-grid">
|
||||||
|
<nuxt-link
|
||||||
|
v-for="org in organizations"
|
||||||
|
:key="org.id"
|
||||||
|
v-tooltip="org.name"
|
||||||
|
class="organization"
|
||||||
|
:to="`/organization/${org.slug}`"
|
||||||
|
>
|
||||||
|
<Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="xs" />
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,6 +397,10 @@ const messages = defineMessages({
|
|||||||
id: 'profile.user-id',
|
id: 'profile.user-id',
|
||||||
defaultMessage: 'User ID: {id}',
|
defaultMessage: 'User ID: {id}',
|
||||||
},
|
},
|
||||||
|
profileOrganizations: {
|
||||||
|
id: 'profile.label.organizations',
|
||||||
|
defaultMessage: 'Organizations',
|
||||||
|
},
|
||||||
profileManageProjectsButton: {
|
profileManageProjectsButton: {
|
||||||
id: 'profile.button.manage-projects',
|
id: 'profile.button.manage-projects',
|
||||||
defaultMessage: 'Manage projects',
|
defaultMessage: 'Manage projects',
|
||||||
@@ -419,7 +440,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
profileNoCollectionsLabel: {
|
profileNoCollectionsLabel: {
|
||||||
id: 'profile.label.no-collections',
|
id: 'profile.label.no-collections',
|
||||||
defaultMessage: 'This user has no collection!',
|
defaultMessage: 'This user has no collections!',
|
||||||
},
|
},
|
||||||
profileNoCollectionsAuthLabel: {
|
profileNoCollectionsAuthLabel: {
|
||||||
id: 'profile.label.no-collections-auth',
|
id: 'profile.label.no-collections-auth',
|
||||||
@@ -432,32 +453,38 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let user, projects, collections
|
let user, projects, organizations, collections
|
||||||
try {
|
try {
|
||||||
;[{ data: user }, { data: projects }, { data: collections }] = await Promise.all([
|
;[{ data: user }, { data: projects }, { data: organizations }, { data: collections }] =
|
||||||
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
|
await Promise.all([
|
||||||
useAsyncData(
|
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
|
||||||
`user/${route.params.id}/projects`,
|
useAsyncData(
|
||||||
() => useBaseFetch(`user/${route.params.id}/projects`),
|
`user/${route.params.id}/projects`,
|
||||||
{
|
() => useBaseFetch(`user/${route.params.id}/projects`),
|
||||||
transform: (projects) => {
|
{
|
||||||
for (const project of projects) {
|
transform: (projects) => {
|
||||||
project.categories = project.categories.concat(project.loaders)
|
for (const project of projects) {
|
||||||
project.project_type = data.$getProjectTypeForUrl(
|
project.categories = project.categories.concat(project.loaders)
|
||||||
project.project_type,
|
project.project_type = data.$getProjectTypeForUrl(
|
||||||
project.categories,
|
project.project_type,
|
||||||
tags.value
|
project.categories,
|
||||||
)
|
tags.value
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return projects
|
return projects
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
useAsyncData(`user/${route.params.id}/collections`, () =>
|
useAsyncData(`user/${route.params.id}/organizations`, () =>
|
||||||
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 })
|
useBaseFetch(`user/${route.params.id}/organizations`, {
|
||||||
),
|
apiVersion: 3,
|
||||||
])
|
})
|
||||||
|
),
|
||||||
|
useAsyncData(`user/${route.params.id}/collections`, () =>
|
||||||
|
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 })
|
||||||
|
),
|
||||||
|
])
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({
|
throw createError({
|
||||||
fatal: true,
|
fatal: true,
|
||||||
@@ -497,12 +524,18 @@ useSeoMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const projectTypes = computed(() => {
|
const projectTypes = computed(() => {
|
||||||
const obj = { collection: true }
|
const obj = {}
|
||||||
|
|
||||||
|
if (collections.value.length > 0) {
|
||||||
|
obj.collection = true
|
||||||
|
}
|
||||||
|
|
||||||
for (const project of projects.value) {
|
for (const project of projects.value) {
|
||||||
obj[project.project_type] = true
|
obj[project.project_type] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete obj.project
|
||||||
|
|
||||||
return Object.keys(obj)
|
return Object.keys(obj)
|
||||||
})
|
})
|
||||||
const sumDownloads = computed(() => {
|
const sumDownloads = computed(() => {
|
||||||
@@ -594,9 +627,24 @@ export default defineNuxtComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.organizations-grid {
|
||||||
|
// 5 wide
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
grid-gap: var(--gap-sm);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.collections-grid {
|
.collections-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
gap: var(--gap-lg);
|
gap: var(--gap-lg);
|
||||||
|
|
||||||
.collection-item {
|
.collection-item {
|
||||||
@@ -658,6 +706,7 @@ export default defineNuxtComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-header-wrapper {
|
.user-header-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0 auto -1.5rem;
|
margin: 0 auto -1.5rem;
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ export const formatCategory = (name) => {
|
|||||||
return 'Colored Lighting'
|
return 'Colored Lighting'
|
||||||
} else if (name === 'optifine') {
|
} else if (name === 'optifine') {
|
||||||
return 'OptiFine'
|
return 'OptiFine'
|
||||||
|
} else if (name === 'mrpack') {
|
||||||
|
return 'Modpack'
|
||||||
|
} else if (name === 'minecraft') {
|
||||||
|
return 'Resource Pack'
|
||||||
|
} else if (name === 'vanilla') {
|
||||||
|
return 'Vanilla Shader'
|
||||||
}
|
}
|
||||||
|
|
||||||
return capitalizeString(name)
|
return capitalizeString(name)
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -38,8 +38,8 @@ dependencies:
|
|||||||
specifier: ^13.0.1
|
specifier: ^13.0.1
|
||||||
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
|
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
|
||||||
omorphia:
|
omorphia:
|
||||||
specifier: '=0.7.1'
|
specifier: '=0.7.2'
|
||||||
version: 0.7.1(vue@3.3.4)
|
version: 0.7.2(vue@3.3.4)
|
||||||
qrcode.vue:
|
qrcode.vue:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0(vue@3.3.4)
|
version: 3.4.0(vue@3.3.4)
|
||||||
@@ -6668,8 +6668,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==}
|
resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/omorphia@0.7.1(vue@3.3.4):
|
/omorphia@0.7.2(vue@3.3.4):
|
||||||
resolution: {integrity: sha512-YQ+u+V52LxeWaEGjEfQk6h+cF//Q9UNSiQLcpDyacTqmrsPYEQstWriBpapANG3ZsPTyAAdkGZePj5uBdYcuIg==}
|
resolution: {integrity: sha512-gyVPehjbH4Jkc4C5bAEVe1/hir2BJuzenZmEalRjnGdVxlTleHzQBM9RjeKtOkXwIbrp+z+rivGc/uRLcRsttA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.3.4
|
vue: ^3.3.4
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
4
utils/permissions.ts
Normal file
4
utils/permissions.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const isPermission = (perms?: number, bitflag?: number) => {
|
||||||
|
if (!perms || !bitflag) return false
|
||||||
|
return (perms & bitflag) === bitflag
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user