1
0

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:
Carter
2024-01-06 15:09:26 -08:00
committed by GitHub
parent 1108b0264e
commit d893765b24
44 changed files with 4092 additions and 1037 deletions

View 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

View File

@@ -1133,6 +1133,10 @@ button {
font-size: var(--font-size-lg);
font-weight: bold;
}
&.no-margin {
margin: 0;
}
}
.project-list {

View File

@@ -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({

View File

@@ -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">&nbsp;</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(() => {

View 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>

View 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>

View File

@@ -42,6 +42,7 @@
class="tags"
>
<EnvironmentIndicator
v-if="clientSide && serverSide"
:type-only="moderation"
:client-side="clientSide"
:server-side="serverSide"

View File

@@ -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>

View File

@@ -370,10 +370,11 @@ svg {
.bar-chart {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.title-bar {

View File

@@ -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()

View 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
}

View 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()
}
}

View File

@@ -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)

View File

@@ -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',
})
}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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 />

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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>

View 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>

View File

@@ -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()

View File

@@ -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',

View File

@@ -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
View 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>

View File

@@ -0,0 +1 @@
<template><div /></template>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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;

View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1,4 @@
export const isPermission = (perms?: number, bitflag?: number) => {
if (!perms || !bitflag) return false
return (perms & bitflag) === bitflag
}