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-weight: bold;
|
||||
}
|
||||
|
||||
&.no-margin {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.project-list {
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
<div class="markdown-body">
|
||||
<p>New projects are created as drafts and can be found under your profile page.</p>
|
||||
</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">
|
||||
<span class="label__title">Name<span class="required">*</span></span>
|
||||
</label>
|
||||
@@ -28,9 +20,7 @@
|
||||
<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/{{ getProjectType() ? getProjectType().id : '???' }}/
|
||||
</div>
|
||||
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
|
||||
<input
|
||||
id="slug"
|
||||
v-model="slug"
|
||||
@@ -40,6 +30,25 @@
|
||||
@input="manualSlug = true"
|
||||
/>
|
||||
</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">
|
||||
<span class="label__title">Summary<span class="required">*</span></span>
|
||||
<span class="label__description"
|
||||
@@ -64,26 +73,23 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
import CrossIcon from '~/assets/images/utils/x.svg'
|
||||
import CheckIcon from '~/assets/images/utils/right-arrow.svg'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import Chips from '~/components/ui/Chips.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Chips,
|
||||
CrossIcon,
|
||||
CheckIcon,
|
||||
Modal,
|
||||
Multiselect,
|
||||
},
|
||||
props: {
|
||||
itemType: {
|
||||
organizationId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
itemId: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
@@ -93,80 +99,68 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projectType: this.tags.projectTypes[0].display,
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
manualSlug: false,
|
||||
visibilities: [
|
||||
{
|
||||
actual: 'approved',
|
||||
display: 'Public',
|
||||
},
|
||||
{
|
||||
actual: 'private',
|
||||
display: 'Private',
|
||||
},
|
||||
{
|
||||
actual: 'unlisted',
|
||||
display: 'Unlisted',
|
||||
},
|
||||
],
|
||||
visibility: {
|
||||
actual: 'approved',
|
||||
display: 'Public',
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancel() {
|
||||
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() {
|
||||
startLoading()
|
||||
|
||||
const projectType = this.getProjectType()
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
formData.append(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
title: this.name.trim(),
|
||||
project_type: projectType.actual,
|
||||
slug: this.slug,
|
||||
description: this.description.trim(),
|
||||
body: '',
|
||||
initial_versions: [],
|
||||
team_members: [
|
||||
{
|
||||
user_id: auth.value.user.id,
|
||||
name: auth.value.user.username,
|
||||
role: 'Owner',
|
||||
},
|
||||
],
|
||||
categories: [],
|
||||
client_side: this.getClientSide(),
|
||||
server_side: this.getServerSide(),
|
||||
license_id: 'LicenseRef-Unknown',
|
||||
is_draft: true,
|
||||
})
|
||||
)
|
||||
const projectData = {
|
||||
title: this.name.trim(),
|
||||
project_type: 'mod',
|
||||
slug: this.slug,
|
||||
description: this.description.trim(),
|
||||
body: '',
|
||||
requested_status: this.visibility.actual,
|
||||
initial_versions: [],
|
||||
team_members: [
|
||||
{
|
||||
user_id: auth.value.user.id,
|
||||
name: auth.value.user.username,
|
||||
role: 'Owner',
|
||||
},
|
||||
],
|
||||
categories: [],
|
||||
client_side: 'required',
|
||||
server_side: 'required',
|
||||
license_id: 'LicenseRef-Unknown',
|
||||
is_draft: true,
|
||||
}
|
||||
|
||||
if (this.organizationId) {
|
||||
projectData.organization_id = this.organizationId
|
||||
}
|
||||
|
||||
formData.append('data', JSON.stringify(projectData))
|
||||
|
||||
try {
|
||||
await useBaseFetch('project', {
|
||||
@@ -181,12 +175,9 @@ export default {
|
||||
await this.$router.push({
|
||||
name: 'type-id',
|
||||
params: {
|
||||
type: projectType.id,
|
||||
type: 'project',
|
||||
id: this.slug,
|
||||
},
|
||||
state: {
|
||||
overrideProjectType: projectType.id,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
|
||||
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
|
||||
</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">
|
||||
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
@@ -31,6 +38,10 @@
|
||||
class="moderation-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" />
|
||||
<NotificationIcon v-else />
|
||||
</template>
|
||||
@@ -54,6 +65,19 @@
|
||||
>.
|
||||
</span>
|
||||
</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">
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
@@ -154,7 +178,7 @@
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="compact" class="notification__actions">
|
||||
<template v-if="type === 'team_invite'">
|
||||
<template v-if="type === 'team_invite' || type === 'organization_invite'">
|
||||
<button
|
||||
v-tooltip="`Accept`"
|
||||
class="iconified-button square-button brand-button button-transparent"
|
||||
@@ -191,7 +215,9 @@
|
||||
</div>
|
||||
<div v-else class="notification__actions">
|
||||
<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
|
||||
class="iconified-button brand-button"
|
||||
@click="
|
||||
@@ -322,6 +348,7 @@ const report = computed(() => props.notification.extra_data.report)
|
||||
const project = computed(() => props.notification.extra_data.project)
|
||||
const version = computed(() => props.notification.extra_data.version)
|
||||
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 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"
|
||||
>
|
||||
<EnvironmentIndicator
|
||||
v-if="clientSide && serverSide"
|
||||
:type-only="moderation"
|
||||
:client-side="clientSide"
|
||||
:server-side="serverSide"
|
||||
|
||||
@@ -54,7 +54,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<RequiredIcon
|
||||
v-if="nag.status === 'required'"
|
||||
@@ -101,7 +105,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { formatProjectType } from '~/plugins/shorthands.js'
|
||||
|
||||
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
|
||||
import DropdownIcon from '~/assets/images/utils/dropdown.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 ModerationIcon from '~/assets/images/sidebar/admin.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 {
|
||||
components: {
|
||||
ChevronRightIcon,
|
||||
DropdownIcon,
|
||||
CheckIcon,
|
||||
CrossIcon,
|
||||
RequiredIcon,
|
||||
SuggestionIcon,
|
||||
ModerationIcon,
|
||||
SendIcon,
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
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',
|
||||
})
|
||||
}
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
featuredGalleryImage() {
|
||||
return this.project.gallery.find((img) => img.featured)
|
||||
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 () => {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: 'setProcessing function not found',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
nags() {
|
||||
return [
|
||||
{
|
||||
condition:
|
||||
this.project.body === '' || this.project.body.startsWith('# Placeholder description'),
|
||||
title: 'Add a description',
|
||||
id: 'add-description',
|
||||
description:
|
||||
"A description that clearly describes the project's purpose and function is required.",
|
||||
status: 'required',
|
||||
link: {
|
||||
path: 'settings/description',
|
||||
title: 'Visit description settings',
|
||||
hide: this.routeName === 'type-id-settings-description',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: !this.project.icon_url,
|
||||
title: 'Add an icon',
|
||||
id: 'add-icon',
|
||||
description:
|
||||
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'settings',
|
||||
title: 'Visit general settings',
|
||||
hide: this.routeName === 'type-id-settings',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: !this.featuredGalleryImage,
|
||||
title: 'Feature a gallery image',
|
||||
id: 'feature-gallery-image',
|
||||
description: 'Featured gallery images may be the first impression of many users.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'gallery',
|
||||
title: 'Visit gallery page',
|
||||
hide: this.routeName === 'type-id-gallery',
|
||||
},
|
||||
},
|
||||
{
|
||||
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.',
|
||||
status: 'required',
|
||||
link: {
|
||||
path: 'versions',
|
||||
title: 'Visit versions page',
|
||||
hide: this.routeName === 'type-id-versions',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: this.project.categories.length < 1,
|
||||
title: 'Select tags',
|
||||
id: 'select-tags',
|
||||
description: 'Select all tags that apply to your project.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'settings/tags',
|
||||
title: 'Visit tag settings',
|
||||
hide: this.routeName === 'type-id-settings-tags',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: !(
|
||||
this.project.issues_url ||
|
||||
this.project.source_url ||
|
||||
this.project.wiki_url ||
|
||||
this.project.discord_url ||
|
||||
this.project.donation_urls.length > 0
|
||||
),
|
||||
title: 'Add external links',
|
||||
id: 'add-links',
|
||||
description:
|
||||
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'settings/links',
|
||||
title: 'Visit links settings',
|
||||
hide: this.routeName === 'type-id-settings-links',
|
||||
},
|
||||
},
|
||||
{
|
||||
hide:
|
||||
this.project.project_type === 'resourcepack' ||
|
||||
this.project.project_type === 'plugin' ||
|
||||
this.project.project_type === 'shader' ||
|
||||
this.project.project_type === 'datapack',
|
||||
condition:
|
||||
this.project.client_side === 'unknown' || this.project.server_side === 'unknown',
|
||||
title: 'Select supported environments',
|
||||
id: 'select-environments',
|
||||
description: `Select if the ${this.$formatProjectType(
|
||||
this.project.project_type
|
||||
).toLowerCase()} functions on the client-side and/or server-side.`,
|
||||
status: 'required',
|
||||
link: {
|
||||
path: 'settings',
|
||||
title: 'Visit general settings',
|
||||
hide: this.routeName === 'type-id-settings',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: this.project.license.id === 'LicenseRef-Unknown',
|
||||
title: 'Select license',
|
||||
id: 'select-license',
|
||||
description: `Select the license your ${this.$formatProjectType(
|
||||
this.project.project_type
|
||||
).toLowerCase()} is distributed under.`,
|
||||
status: 'required',
|
||||
link: {
|
||||
path: 'settings/license',
|
||||
title: 'Visit license settings',
|
||||
hide: this.routeName === 'type-id-settings-license',
|
||||
},
|
||||
},
|
||||
{
|
||||
hide: this.project.status !== 'draft',
|
||||
condition: true,
|
||||
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: this.submitForReview,
|
||||
title: 'Submit for review',
|
||||
disabled: () =>
|
||||
this.nags.filter((x) => x.condition && x.status === 'required').length > 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
hide: !this.tags.rejectedStatuses.includes(this.project.status),
|
||||
condition: true,
|
||||
title: 'Resubmit for review',
|
||||
id: 'resubmit-for-review',
|
||||
description: `Your project has been ${this.project.status} by
|
||||
},
|
||||
toggleCollapsed: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: 'toggleCollapsed function not found',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
updateMembers: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: 'updateMembers function not found',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured))
|
||||
|
||||
const nags = computed(() => [
|
||||
{
|
||||
condition: props.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.',
|
||||
status: 'required',
|
||||
link: {
|
||||
path: 'versions',
|
||||
title: 'Visit versions page',
|
||||
hide: props.routeName === 'type-id-versions',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition:
|
||||
props.project.body === '' || props.project.body.startsWith('# Placeholder description'),
|
||||
title: 'Add a description',
|
||||
id: 'add-description',
|
||||
description:
|
||||
"A description that clearly describes the project's purpose and function is required.",
|
||||
status: 'required',
|
||||
link: {
|
||||
path: 'settings/description',
|
||||
title: 'Visit description settings',
|
||||
hide: props.routeName === 'type-id-settings-description',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: !props.project.icon_url,
|
||||
title: 'Add an icon',
|
||||
id: 'add-icon',
|
||||
description:
|
||||
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'settings',
|
||||
title: 'Visit general settings',
|
||||
hide: props.routeName === 'type-id-settings',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: props.project.gallery.length === 0 || !featuredGalleryImage,
|
||||
title: 'Feature a gallery image',
|
||||
id: 'feature-gallery-image',
|
||||
description: 'Featured gallery images may be the first impression of many users.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'gallery',
|
||||
title: 'Visit gallery page',
|
||||
hide: props.routeName === 'type-id-gallery',
|
||||
},
|
||||
},
|
||||
{
|
||||
hide: props.project.versions.length === 0,
|
||||
condition: props.project.categories.length < 1,
|
||||
title: 'Select tags',
|
||||
id: 'select-tags',
|
||||
description: 'Select all tags that apply to your project.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'settings/tags',
|
||||
title: 'Visit tag settings',
|
||||
hide: props.routeName === 'type-id-settings-tags',
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: !(
|
||||
props.project.issues_url ||
|
||||
props.project.source_url ||
|
||||
props.project.wiki_url ||
|
||||
props.project.discord_url ||
|
||||
props.project.donation_urls.length > 0
|
||||
),
|
||||
title: 'Add external links',
|
||||
id: 'add-links',
|
||||
description:
|
||||
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
|
||||
status: 'suggestion',
|
||||
link: {
|
||||
path: 'settings/links',
|
||||
title: 'Visit links settings',
|
||||
hide: props.routeName === 'type-id-settings-links',
|
||||
},
|
||||
},
|
||||
{
|
||||
hide:
|
||||
props.project.versions.length === 0 ||
|
||||
props.project.project_type === 'resourcepack' ||
|
||||
props.project.project_type === 'plugin' ||
|
||||
props.project.project_type === 'shader' ||
|
||||
props.project.project_type === 'datapack',
|
||||
condition:
|
||||
props.project.client_side === 'unknown' ||
|
||||
props.project.server_side === 'unknown' ||
|
||||
(props.project.client_side === 'unsupported' && props.project.server_side === 'unsupported'),
|
||||
title: 'Select supported environments',
|
||||
id: 'select-environments',
|
||||
description: `Select if the ${formatProjectType(
|
||||
props.project.project_type
|
||||
).toLowerCase()} functions on the client-side and/or server-side.`,
|
||||
status: 'required',
|
||||
link: {
|
||||
path: 'settings',
|
||||
title: 'Visit general settings',
|
||||
hide: props.routeName === 'type-id-settings',
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
addressing the staff's message.`,
|
||||
status: 'review',
|
||||
link: {
|
||||
path: 'moderation',
|
||||
title: 'Visit moderation page',
|
||||
hide: this.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()
|
||||
}
|
||||
status: 'review',
|
||||
link: {
|
||||
path: 'moderation',
|
||||
title: 'Visit moderation page',
|
||||
hide: props.routeName === 'type-id-moderation',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -370,10 +370,11 @@ svg {
|
||||
|
||||
.bar-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="analytics.error.value">
|
||||
{{ analytics.error.value }}
|
||||
<div v-if="analytics.error.value" class="universal-card">
|
||||
<h2>
|
||||
<span class="label__title">Error</span>
|
||||
</h2>
|
||||
<div>
|
||||
{{ analytics.error.value }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="graphs">
|
||||
<div class="graphs__vertical-bar">
|
||||
@@ -18,7 +23,7 @@
|
||||
:class="`clickable button-base ${
|
||||
selectedChart === 'downloads' ? 'button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'downloads')"
|
||||
:onclick="() => setSelectedChart('downloads')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
@@ -35,7 +40,7 @@
|
||||
:class="`clickable button-base ${
|
||||
selectedChart === 'views' ? 'button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'views')"
|
||||
:onclick="() => setSelectedChart('views')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
@@ -52,7 +57,7 @@
|
||||
:class="`clickable button-base ${
|
||||
selectedChart === 'revenue' ? 'button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'revenue')"
|
||||
:onclick="() => setSelectedChart('revenue')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
@@ -118,7 +123,9 @@
|
||||
<div class="country-data">
|
||||
<Card
|
||||
v-if="
|
||||
analytics.formattedData.value?.downloadsByCountry && selectedChart === 'downloads'
|
||||
analytics.formattedData.value?.downloadsByCountry &&
|
||||
selectedChart === 'downloads' &&
|
||||
analytics.formattedData.value.downloadsByCountry.data.length > 0
|
||||
"
|
||||
class="country-downloads"
|
||||
>
|
||||
@@ -169,7 +176,11 @@
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<label>
|
||||
@@ -183,14 +194,20 @@
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<img
|
||||
:src="`https://flagcdn.com/h240/${name.toLowerCase()}.png`"
|
||||
:alt="name"
|
||||
:src="
|
||||
name.toLowerCase() === 'xx' || !name
|
||||
? 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
: countryCodeToFlag(name)
|
||||
"
|
||||
alt="Hidden country"
|
||||
class="country-flag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
@@ -225,6 +242,8 @@ import dayjs from 'dayjs'
|
||||
import { defineProps, ref, computed } from 'vue'
|
||||
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projects?: any[]
|
||||
@@ -247,7 +266,18 @@ const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) =>
|
||||
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
|
||||
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'
|
||||
|
||||
async function getBulk(type, ids) {
|
||||
async function getBulk(type, ids, apiVersion = 2) {
|
||||
if (ids.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export async function fetchNotifications() {
|
||||
const threadIds = []
|
||||
const userIds = []
|
||||
const versionIds = []
|
||||
const organizationIds = []
|
||||
|
||||
for (const notification of notifications.value) {
|
||||
if (notification.body) {
|
||||
@@ -40,6 +41,9 @@ export async function fetchNotifications() {
|
||||
if (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)
|
||||
}
|
||||
|
||||
const projects = await getBulk('projects', projectIds)
|
||||
const threads = await getBulk('threads', threadIds)
|
||||
const users = await getBulk('users', userIds)
|
||||
const [projects, threads, users, organizations] = await Promise.all([
|
||||
getBulk('projects', projectIds),
|
||||
getBulk('threads', threadIds),
|
||||
getBulk('users', userIds),
|
||||
getBulk('organizations', organizationIds, 3),
|
||||
])
|
||||
|
||||
for (const notification of notifications.value) {
|
||||
notification.extra_data = {}
|
||||
@@ -73,6 +80,11 @@ export async function fetchNotifications() {
|
||||
(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) {
|
||||
notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const acceptTeamInvite = async (teamId) => {
|
||||
await useBaseFetch(`team/${teamId}/join`, {
|
||||
apiVersion: 3,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
@@ -9,6 +10,7 @@ export const removeSelfFromTeam = async (teamId) => {
|
||||
}
|
||||
export const removeTeamMember = async (teamId, userId) => {
|
||||
await useBaseFetch(`team/${teamId}/members/${userId}`, {
|
||||
apiVersion: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
<span class="title">Create a project</span>
|
||||
</button>
|
||||
<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">
|
||||
<NotificationIcon class="icon" />
|
||||
<span class="title">Notifications</span>
|
||||
@@ -85,10 +89,6 @@
|
||||
<ChartIcon class="icon" />
|
||||
<span class="title">Dashboard</span>
|
||||
</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">
|
||||
<SettingsIcon class="icon" />
|
||||
<span class="title">Settings</span>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"markdown-it": "^13.0.1",
|
||||
"omorphia": "=0.7.1",
|
||||
"omorphia": "=0.7.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"vue-multiselect": "^3.0.0-alpha.2",
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
<Breadcrumbs
|
||||
current-title="Settings"
|
||||
: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}`,
|
||||
label: project.title,
|
||||
@@ -126,10 +131,13 @@
|
||||
v-model:members="members"
|
||||
v-model:all-members="allMembers"
|
||||
v-model:dependencies="dependencies"
|
||||
v-model:organization="organization"
|
||||
:current-member="currentMember"
|
||||
:patch-project="patchProject"
|
||||
:patch-icon="patchIcon"
|
||||
:update-icon="resetProject"
|
||||
:reset-project="resetProject"
|
||||
:reset-organization="resetOrganization"
|
||||
:reset-members="resetMembers"
|
||||
:route="route"
|
||||
/>
|
||||
</div>
|
||||
@@ -478,11 +486,15 @@
|
||||
v-model:members="members"
|
||||
v-model:all-members="allMembers"
|
||||
v-model:dependencies="dependencies"
|
||||
v-model:organization="organization"
|
||||
:current-member="currentMember"
|
||||
:reset-project="resetProject"
|
||||
:reset-organization="resetOrganization"
|
||||
:reset-members="resetMembers"
|
||||
:route="route"
|
||||
/>
|
||||
</section>
|
||||
<div class="card normal-page__info">
|
||||
<div class="universal-card normal-page__info">
|
||||
<template
|
||||
v-if="
|
||||
project.issues_url ||
|
||||
@@ -618,6 +630,17 @@
|
||||
<hr class="card-divider" />
|
||||
</template>
|
||||
<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
|
||||
v-for="member in members"
|
||||
:key="member.user.id"
|
||||
@@ -787,6 +810,7 @@ import { reportProject } from '~/utils/report-helpers.ts'
|
||||
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||
import { userCollectProject } from '~/composables/user.js'
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
import OrganizationIcon from '~/assets/images/utils/organization.svg'
|
||||
|
||||
const data = useNuxtApp()
|
||||
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 {
|
||||
;[
|
||||
{ data: project },
|
||||
{ data: allMembers },
|
||||
{ data: project, refresh: resetProject },
|
||||
{ data: allMembers, refresh: resetMembers },
|
||||
{ data: dependencies },
|
||||
{ data: featuredVersions },
|
||||
{ data: versions },
|
||||
{ data: organization, refresh: resetOrganization },
|
||||
] = await Promise.all([
|
||||
useAsyncData(`project/${route.params.id}`, () => useBaseFetch(`project/${route.params.id}`), {
|
||||
transform: (project) => {
|
||||
@@ -838,10 +871,6 @@ try {
|
||||
project.loaders,
|
||||
tags.value
|
||||
)
|
||||
|
||||
if (process.client && history.state && history.state.overrideProjectType) {
|
||||
project.project_type = history.state.overrideProjectType
|
||||
}
|
||||
}
|
||||
|
||||
return project
|
||||
@@ -870,6 +899,9 @@ try {
|
||||
useAsyncData(`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))
|
||||
@@ -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))
|
||||
const currentMember = ref(
|
||||
auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null
|
||||
)
|
||||
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
|
||||
// The rest of the members should be sorted by role, then by name
|
||||
const 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 (
|
||||
!currentMember.value &&
|
||||
auth.value.user &&
|
||||
tags.value.staffRoles.includes(auth.value.user.role)
|
||||
) {
|
||||
currentMember.value = {
|
||||
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,
|
||||
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 ? [owner, ...rest] : rest
|
||||
})
|
||||
|
||||
const currentMember = computed(() => {
|
||||
let val = auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null
|
||||
|
||||
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)
|
||||
|
||||
@@ -955,38 +1006,36 @@ const licenseIdDisplay = computed(() => {
|
||||
})
|
||||
const featuredGalleryImage = computed(() => project.value.gallery.find((img) => img.featured))
|
||||
|
||||
const projectTypeDisplay = data.$formatProjectType(
|
||||
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders)
|
||||
const projectTypeDisplay = computed(() =>
|
||||
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')) {
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: project.value.description,
|
||||
ogImage: project.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
robots:
|
||||
title: () => title.value,
|
||||
description: () => description.value,
|
||||
ogTitle: () => title.value,
|
||||
ogDescription: () => project.value.description,
|
||||
ogImage: () => project.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
robots: () =>
|
||||
project.value.status === 'approved' || project.value.status === 'archived'
|
||||
? 'all'
|
||||
: '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() {
|
||||
startLoading()
|
||||
|
||||
@@ -1041,7 +1090,7 @@ const licenseText = ref('')
|
||||
async function getLicenseData() {
|
||||
try {
|
||||
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 {
|
||||
licenseText.value = 'License text could not be retrieved.'
|
||||
}
|
||||
@@ -1364,6 +1413,12 @@ const collapsedChecklist = ref(false)
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,8 @@
|
||||
:max-size="524288000"
|
||||
:accept="acceptFileTypes"
|
||||
prompt="Upload an image"
|
||||
class="brand-button iconified-button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
|
||||
@change="handleFiles"
|
||||
>
|
||||
<UploadIcon />
|
||||
@@ -207,7 +208,14 @@
|
||||
<span class="indicator">
|
||||
<InfoIcon /> Click to choose an image or drag one onto this page
|
||||
</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 class="items">
|
||||
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||
@@ -288,11 +296,13 @@ import {
|
||||
ImageIcon,
|
||||
TransferIcon,
|
||||
ConfirmModal,
|
||||
FileInput,
|
||||
DropArea,
|
||||
} from 'omorphia'
|
||||
import FileInput from '~/components/ui/FileInput.vue'
|
||||
import DropArea from '~/components/ui/DropArea.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
import { isPermission } from '~/utils/permissions.ts'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
@@ -306,6 +316,11 @@ const props = defineProps({
|
||||
return null
|
||||
},
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const title = `${props.project.title} - Gallery`
|
||||
@@ -430,7 +445,7 @@ export default defineNuxtComponent({
|
||||
method: 'POST',
|
||||
body: this.editFile,
|
||||
})
|
||||
await this.updateProject()
|
||||
await this.resetProject()
|
||||
|
||||
this.$refs.modal_edit_item.hide()
|
||||
} catch (err) {
|
||||
@@ -468,7 +483,7 @@ export default defineNuxtComponent({
|
||||
method: 'PATCH',
|
||||
})
|
||||
|
||||
await this.updateProject()
|
||||
await this.resetProject()
|
||||
this.$refs.modal_edit_item.hide()
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
@@ -495,7 +510,7 @@ export default defineNuxtComponent({
|
||||
}
|
||||
)
|
||||
|
||||
await this.updateProject()
|
||||
await this.resetProject()
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
@@ -507,16 +522,6 @@ export default defineNuxtComponent({
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<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>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -104,10 +104,13 @@ const props = defineProps({
|
||||
return null
|
||||
},
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:project'])
|
||||
|
||||
const app = useNuxtApp()
|
||||
const auth = await useAuth()
|
||||
|
||||
@@ -130,7 +133,7 @@ async function setStatus(status) {
|
||||
})
|
||||
const project = props.project
|
||||
project.status = status
|
||||
emit('update:project', project)
|
||||
await props.resetProject()
|
||||
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
|
||||
} catch (err) {
|
||||
app.$notify({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<Card>
|
||||
<div class="universal-card">
|
||||
<div class="markdown-disclaimer">
|
||||
<h2>Description</h2>
|
||||
<span class="label__description">
|
||||
@@ -29,12 +29,12 @@
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MarkdownEditor, Card } from 'omorphia'
|
||||
import { MarkdownEditor } from 'omorphia'
|
||||
import Chips from '~/components/ui/Chips.vue'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg'
|
||||
import { renderHighlightedString } from '~/helpers/highlight.js'
|
||||
@@ -42,7 +42,6 @@ import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Card,
|
||||
Chips,
|
||||
SaveIcon,
|
||||
MarkdownEditor,
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
</div>
|
||||
<template
|
||||
v-if="
|
||||
project.versions.length !== 0 &&
|
||||
project.project_type !== 'resourcepack' &&
|
||||
project.project_type !== 'plugin' &&
|
||||
project.project_type !== 'shader' &&
|
||||
@@ -110,6 +111,7 @@
|
||||
<Multiselect
|
||||
id="project-env-client"
|
||||
v-model="clientSide"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="sideTypes"
|
||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||
@@ -133,6 +135,7 @@
|
||||
<Multiselect
|
||||
id="project-env-server"
|
||||
v-model="serverSide"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="sideTypes"
|
||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||
@@ -190,6 +193,7 @@
|
||||
<Multiselect
|
||||
id="project-visibility"
|
||||
v-model="visibility"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="tags.approvedStatuses"
|
||||
:custom-label="(value) => $formatProjectStatus(value)"
|
||||
@@ -236,8 +240,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import ModalConfirm from '~/components/ui/ModalConfirm.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 CheckIcon from '~/assets/images/utils/check.svg'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Avatar,
|
||||
ModalConfirm,
|
||||
FileInput,
|
||||
Multiselect,
|
||||
UploadIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
ExitIcon,
|
||||
CheckIcon,
|
||||
IssuesIcon,
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
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',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
setup() {
|
||||
const tags = useTags()
|
||||
|
||||
return { tags }
|
||||
patchProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: this.project.title,
|
||||
slug: this.project.slug,
|
||||
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,
|
||||
}
|
||||
patchIcon: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
computed: {
|
||||
hasPermission() {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
||||
},
|
||||
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',
|
||||
})
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
<style lang="scss" scoped>
|
||||
.visibility-info {
|
||||
@@ -467,7 +434,7 @@ svg {
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
.small-multiselect {
|
||||
max-width: 15rem;
|
||||
}
|
||||
</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
|
||||
that apply.
|
||||
</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">
|
||||
<h4>
|
||||
<span class="label__title">{{ $formatCategoryHeader(header) }}</span>
|
||||
<span class="label__title"><StarIcon /> Featured tags</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>
|
||||
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 categoryLists[header]"
|
||||
:key="`category-${header}-${category.name}`"
|
||||
:model-value="selectedTags.includes(category)"
|
||||
:description="$formatCategory(category.name)"
|
||||
v-for="category in selectedTags"
|
||||
:key="`featured-category-${category.name}`"
|
||||
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
|
||||
v-if="header !== 'resolutions' && category.icon"
|
||||
v-if="category.header !== 'resolutions' && category.icon"
|
||||
aria-hidden="true"
|
||||
class="icon"
|
||||
v-html="category.icon"
|
||||
@@ -58,39 +96,7 @@
|
||||
</Checkbox>
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -541,7 +541,7 @@
|
||||
:custom-label="(value) => $formatCategory(value)"
|
||||
:loading="tags.loaders.length === 0"
|
||||
:multiple="true"
|
||||
:searchable="false"
|
||||
:searchable="true"
|
||||
:show-no-results="false"
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
@@ -735,6 +735,11 @@ export default defineNuxtComponent({
|
||||
return {}
|
||||
},
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
async setup(props) {
|
||||
const data = useNuxtApp()
|
||||
@@ -1135,13 +1140,13 @@ export default defineNuxtComponent({
|
||||
})
|
||||
}
|
||||
|
||||
const newEditedVersions = await this.resetProjectVersions()
|
||||
await this.resetProjectVersions()
|
||||
|
||||
await this.$router.replace(
|
||||
`/${this.project.project_type}/${
|
||||
this.project.slug ? this.project.slug : this.project.id
|
||||
}/version/${encodeURI(
|
||||
newEditedVersions.find((x) => x.id === this.version.id).displayUrlEnding
|
||||
this.versions.find((x) => x.id === this.version.id).displayUrlEnding
|
||||
)}`
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -1316,6 +1321,7 @@ export default defineNuxtComponent({
|
||||
useBaseFetch(`project/${this.version.project_id}/version`),
|
||||
useBaseFetch(`project/${this.version.project_id}/version?featured=true`),
|
||||
useBaseFetch(`project/${this.version.project_id}/dependencies`),
|
||||
this.resetProject(),
|
||||
])
|
||||
|
||||
const newCreatedVersions = this.$computeVersions(versions, this.members)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
:max-size="524288000"
|
||||
:accept="acceptFileFromProjectType(project.project_type)"
|
||||
prompt="Upload a version"
|
||||
class="brand-button iconified-button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!isPermission(currentMember?.permissions, 1 << 0)"
|
||||
@change="handleFiles"
|
||||
>
|
||||
<UploadIcon />
|
||||
|
||||
@@ -437,16 +437,14 @@ if (!collection.value) {
|
||||
const title = `${collection.value.name} - Collection`
|
||||
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({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: collection.value.description,
|
||||
ogImage: collection.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
robots: collection.value.status === 'listed' ? 'all' : 'noindex',
|
||||
})
|
||||
}
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: collection.value.description,
|
||||
ogImage: collection.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
robots: collection.value.status === 'listed' ? 'all' : 'noindex',
|
||||
})
|
||||
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
@@ -462,6 +460,8 @@ const projectTypes = computed(() => {
|
||||
obj[project.project_type] = true
|
||||
}
|
||||
|
||||
delete obj.project
|
||||
|
||||
return Object.keys(obj)
|
||||
})
|
||||
|
||||
@@ -656,6 +656,8 @@ function showPreviewImage(files) {
|
||||
|
||||
.title {
|
||||
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.collection-label {
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
<NavStackItem link="/dashboard/notifications" label="Notifications">
|
||||
<NotificationsIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/follows" label="Followed projects">
|
||||
<HeartIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/reports" label="Active reports">
|
||||
<ReportIcon />
|
||||
</NavStackItem>
|
||||
@@ -24,6 +21,9 @@
|
||||
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
||||
<ListIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem v-if="true" link="/dashboard/organizations" label="Organizations">
|
||||
<OrganizationIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/collections" label="Collections">
|
||||
<LibraryIcon />
|
||||
</NavStackItem>
|
||||
@@ -48,7 +48,7 @@ import CurrencyIcon from '~/assets/images/utils/currency.svg'
|
||||
import ListIcon from '~/assets/images/utils/list.svg'
|
||||
import ReportIcon from '~/assets/images/utils/report.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({
|
||||
middleware: 'auth',
|
||||
|
||||
@@ -115,6 +115,11 @@ const orderedCollections = computed(() => {
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
gap: var(--gap-md);
|
||||
|
||||
.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'
|
||||
|
||||
const auth = await useAuth()
|
||||
const minWithdraw = ref(0.1)
|
||||
const minWithdraw = ref(0.01)
|
||||
|
||||
async function updateVenmo() {
|
||||
startLoading()
|
||||
|
||||
@@ -85,7 +85,6 @@ async function cancelPayout(id) {
|
||||
await refresh()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
|
||||
@@ -194,7 +194,6 @@ const selectedMethod = computed(() =>
|
||||
const parsedAmount = computed(() => {
|
||||
const regex = /^\$?(\d*(\.\d{2})?)$/gm
|
||||
const matches = regex.exec(amount.value)
|
||||
console.log(matches)
|
||||
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
|
||||
})
|
||||
const fees = computed(() => {
|
||||
@@ -251,7 +250,6 @@ const agreedTerms = ref(false)
|
||||
|
||||
watch(country, async () => {
|
||||
await refreshPayoutMethods()
|
||||
console.log(payoutMethods.value)
|
||||
if (payoutMethods.value && payoutMethods.value[0]) {
|
||||
selectedMethodId.value = payoutMethods.value[0].id
|
||||
}
|
||||
@@ -285,7 +283,6 @@ async function withdraw() {
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
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>
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,6 +397,10 @@ const messages = defineMessages({
|
||||
id: 'profile.user-id',
|
||||
defaultMessage: 'User ID: {id}',
|
||||
},
|
||||
profileOrganizations: {
|
||||
id: 'profile.label.organizations',
|
||||
defaultMessage: 'Organizations',
|
||||
},
|
||||
profileManageProjectsButton: {
|
||||
id: 'profile.button.manage-projects',
|
||||
defaultMessage: 'Manage projects',
|
||||
@@ -419,7 +440,7 @@ const messages = defineMessages({
|
||||
},
|
||||
profileNoCollectionsLabel: {
|
||||
id: 'profile.label.no-collections',
|
||||
defaultMessage: 'This user has no collection!',
|
||||
defaultMessage: 'This user has no collections!',
|
||||
},
|
||||
profileNoCollectionsAuthLabel: {
|
||||
id: 'profile.label.no-collections-auth',
|
||||
@@ -432,32 +453,38 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
let user, projects, collections
|
||||
let user, projects, organizations, collections
|
||||
try {
|
||||
;[{ data: user }, { data: projects }, { data: collections }] = await Promise.all([
|
||||
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
|
||||
useAsyncData(
|
||||
`user/${route.params.id}/projects`,
|
||||
() => useBaseFetch(`user/${route.params.id}/projects`),
|
||||
{
|
||||
transform: (projects) => {
|
||||
for (const project of projects) {
|
||||
project.categories = project.categories.concat(project.loaders)
|
||||
project.project_type = data.$getProjectTypeForUrl(
|
||||
project.project_type,
|
||||
project.categories,
|
||||
tags.value
|
||||
)
|
||||
}
|
||||
;[{ data: user }, { data: projects }, { data: organizations }, { data: collections }] =
|
||||
await Promise.all([
|
||||
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
|
||||
useAsyncData(
|
||||
`user/${route.params.id}/projects`,
|
||||
() => useBaseFetch(`user/${route.params.id}/projects`),
|
||||
{
|
||||
transform: (projects) => {
|
||||
for (const project of projects) {
|
||||
project.categories = project.categories.concat(project.loaders)
|
||||
project.project_type = data.$getProjectTypeForUrl(
|
||||
project.project_type,
|
||||
project.categories,
|
||||
tags.value
|
||||
)
|
||||
}
|
||||
|
||||
return projects
|
||||
},
|
||||
}
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/collections`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 })
|
||||
),
|
||||
])
|
||||
return projects
|
||||
},
|
||||
}
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/organizations`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/organizations`, {
|
||||
apiVersion: 3,
|
||||
})
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/collections`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 })
|
||||
),
|
||||
])
|
||||
} catch {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
@@ -497,12 +524,18 @@ useSeoMeta({
|
||||
})
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const obj = { collection: true }
|
||||
const obj = {}
|
||||
|
||||
if (collections.value.length > 0) {
|
||||
obj.collection = true
|
||||
}
|
||||
|
||||
for (const project of projects.value) {
|
||||
obj[project.project_type] = true
|
||||
}
|
||||
|
||||
delete obj.project
|
||||
|
||||
return Object.keys(obj)
|
||||
})
|
||||
const sumDownloads = computed(() => {
|
||||
@@ -594,9 +627,24 @@ export default defineNuxtComponent({
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.collection-item {
|
||||
@@ -658,6 +706,7 @@ export default defineNuxtComponent({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-header-wrapper {
|
||||
display: flex;
|
||||
margin: 0 auto -1.5rem;
|
||||
|
||||
@@ -227,6 +227,12 @@ export const formatCategory = (name) => {
|
||||
return 'Colored Lighting'
|
||||
} else if (name === '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)
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -38,8 +38,8 @@ dependencies:
|
||||
specifier: ^13.0.1
|
||||
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
|
||||
omorphia:
|
||||
specifier: '=0.7.1'
|
||||
version: 0.7.1(vue@3.3.4)
|
||||
specifier: '=0.7.2'
|
||||
version: 0.7.2(vue@3.3.4)
|
||||
qrcode.vue:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0(vue@3.3.4)
|
||||
@@ -6668,8 +6668,8 @@ packages:
|
||||
resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==}
|
||||
dev: true
|
||||
|
||||
/omorphia@0.7.1(vue@3.3.4):
|
||||
resolution: {integrity: sha512-YQ+u+V52LxeWaEGjEfQk6h+cF//Q9UNSiQLcpDyacTqmrsPYEQstWriBpapANG3ZsPTyAAdkGZePj5uBdYcuIg==}
|
||||
/omorphia@0.7.2(vue@3.3.4):
|
||||
resolution: {integrity: sha512-gyVPehjbH4Jkc4C5bAEVe1/hir2BJuzenZmEalRjnGdVxlTleHzQBM9RjeKtOkXwIbrp+z+rivGc/uRLcRsttA==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.4
|
||||
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