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
+22 -17
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>
+5 -1
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>
+6 -3
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({
+3 -4
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,
+155 -188
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
+65 -59
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"
+9 -3
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)
+2 -1
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 />