You've already forked AstralRinth
forked from didirus/AstralRinth
Migrate to Turborepo (#1251)
This commit is contained in:
1885
apps/frontend/src/pages/[type]/[id].vue
Normal file
1885
apps/frontend/src/pages/[type]/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
253
apps/frontend/src/pages/[type]/[id]/changelog.vue
Normal file
253
apps/frontend/src/pages/[type]/[id]/changelog.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<VersionFilterControl :versions="props.versions" />
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(filteredVersions.length / 20)"
|
||||
class="pagination-before"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
<div class="card changelog-wrapper">
|
||||
<div
|
||||
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
|
||||
:key="version.id"
|
||||
class="changelog-item"
|
||||
>
|
||||
<div
|
||||
:class="`changelog-bar ${version.version_type} ${version.duplicate ? 'duplicate' : ''}`"
|
||||
/>
|
||||
<div class="version-wrapper">
|
||||
<div class="version-header">
|
||||
<div class="version-header-text">
|
||||
<h2 class="name">
|
||||
<nuxt-link
|
||||
:to="`/${props.project.project_type}/${
|
||||
props.project.slug ? props.project.slug : props.project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`"
|
||||
>
|
||||
{{ version.name }}
|
||||
</nuxt-link>
|
||||
</h2>
|
||||
<span v-if="version.author">
|
||||
by
|
||||
<nuxt-link class="text-link" :to="'/user/' + version.author.user.username">{{
|
||||
version.author.user.username
|
||||
}}</nuxt-link>
|
||||
</span>
|
||||
<span>
|
||||
on
|
||||
{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
:href="version.primaryFile.url"
|
||||
class="iconified-button download"
|
||||
:title="`Download ${version.name}`"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-if="version.changelog && !version.duplicate"
|
||||
class="markdown-body"
|
||||
v-html="renderHighlightedString(version.changelog)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(filteredVersions.length / 20)"
|
||||
class="pagination-before"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import DownloadIcon from '~/assets/images/utils/download.svg?component'
|
||||
import { renderHighlightedString } from '~/helpers/highlight.js'
|
||||
import VersionFilterControl from '~/components/ui/VersionFilterControl.vue'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
members: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const title = `${props.project.title} - Changelog`
|
||||
const description = `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
})
|
||||
|
||||
const router = useNativeRouter()
|
||||
const route = useNativeRoute()
|
||||
|
||||
const currentPage = ref(Number(route.query.p ?? 1))
|
||||
const filteredVersions = computed(() => {
|
||||
const selectedGameVersions = getArrayOrString(route.query.g) ?? []
|
||||
const selectedLoaders = getArrayOrString(route.query.l) ?? []
|
||||
const selectedVersionTypes = getArrayOrString(route.query.c) ?? []
|
||||
|
||||
return props.versions.filter(
|
||||
(projectVersion) =>
|
||||
(selectedGameVersions.length === 0 ||
|
||||
selectedGameVersions.some((gameVersion) =>
|
||||
projectVersion.game_versions.includes(gameVersion)
|
||||
)) &&
|
||||
(selectedLoaders.length === 0 ||
|
||||
selectedLoaders.some((loader) => projectVersion.loaders.includes(loader))) &&
|
||||
(selectedVersionTypes.length === 0 ||
|
||||
selectedVersionTypes.includes(projectVersion.version_type))
|
||||
)
|
||||
})
|
||||
|
||||
function switchPage(page) {
|
||||
currentPage.value = page
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
p: currentPage.value !== 1 ? currentPage.value : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.changelog-wrapper {
|
||||
padding-bottom: calc(var(--spacing-card-md) + 0.5rem);
|
||||
}
|
||||
|
||||
.changelog-item {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
padding-left: 1.8rem;
|
||||
|
||||
&:last-child {
|
||||
.changelog-bar.duplicate {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog-bar {
|
||||
--color: var(--color-green);
|
||||
|
||||
&.alpha {
|
||||
--color: var(--color-red);
|
||||
}
|
||||
|
||||
&.release {
|
||||
--color: var(--color-green);
|
||||
}
|
||||
|
||||
&.beta {
|
||||
--color: var(--color-orange);
|
||||
}
|
||||
|
||||
left: 0;
|
||||
top: 0.5rem;
|
||||
width: 0.2rem;
|
||||
min-width: 0.2rem;
|
||||
position: absolute;
|
||||
margin: 0 0.4rem;
|
||||
border-radius: var(--size-rounded-max);
|
||||
min-height: 100%;
|
||||
background-color: var(--color);
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -0.4rem;
|
||||
border-radius: var(--size-rounded-max);
|
||||
background-color: var(--color);
|
||||
}
|
||||
|
||||
&.duplicate {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
transparent 30%,
|
||||
var(--color) 30%,
|
||||
var(--color)
|
||||
);
|
||||
background-size: 100% 10px;
|
||||
}
|
||||
|
||||
&.duplicate {
|
||||
height: calc(100% + 1.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
margin: 0.5rem 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0.2rem;
|
||||
|
||||
.circle {
|
||||
min-width: 0.75rem;
|
||||
min-height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.version-header-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
h2,
|
||||
span {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
margin-left: auto;
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
793
apps/frontend/src/pages/[type]/[id]/gallery.vue
Normal file
793
apps/frontend/src/pages/[type]/[id]/gallery.vue
Normal file
@@ -0,0 +1,793 @@
|
||||
<template>
|
||||
<div>
|
||||
<Modal
|
||||
v-if="currentMember"
|
||||
ref="modal_edit_item"
|
||||
:header="editIndex === -1 ? 'Upload gallery image' : 'Edit gallery item'"
|
||||
>
|
||||
<div class="modal-gallery universal-labels">
|
||||
<div class="gallery-file-input">
|
||||
<div class="file-header">
|
||||
<ImageIcon />
|
||||
<strong>{{ editFile ? editFile.name : 'Current image' }}</strong>
|
||||
<FileInput
|
||||
v-if="editIndex === -1"
|
||||
class="iconified-button raised-button"
|
||||
prompt="Replace"
|
||||
:accept="acceptFileTypes"
|
||||
:max-size="524288000"
|
||||
should-always-reset
|
||||
@change="
|
||||
(x) => {
|
||||
editFile = x[0]
|
||||
showPreviewImage()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TransferIcon />
|
||||
</FileInput>
|
||||
</div>
|
||||
<img
|
||||
:src="
|
||||
previewImage
|
||||
? previewImage
|
||||
: project.gallery[editIndex] && project.gallery[editIndex].url
|
||||
? project.gallery[editIndex].url
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
alt="gallery-preview"
|
||||
/>
|
||||
</div>
|
||||
<label for="gallery-image-title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="gallery-image-title"
|
||||
v-model="editTitle"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="Enter title..."
|
||||
/>
|
||||
<label for="gallery-image-desc">
|
||||
<span class="label__title">Description</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="gallery-image-desc"
|
||||
v-model="editDescription"
|
||||
maxlength="255"
|
||||
placeholder="Enter description..."
|
||||
/>
|
||||
</div>
|
||||
<label for="gallery-image-ordering">
|
||||
<span class="label__title">Order Index</span>
|
||||
</label>
|
||||
<input
|
||||
id="gallery-image-ordering"
|
||||
v-model="editOrder"
|
||||
type="number"
|
||||
placeholder="Enter order index..."
|
||||
/>
|
||||
<label for="gallery-image-featured">
|
||||
<span class="label__title">Featured</span>
|
||||
<span class="label__description">
|
||||
A featured gallery image shows up in search and your project card. Only one gallery
|
||||
image can be featured.
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="!editFeatured"
|
||||
id="gallery-image-featured"
|
||||
class="iconified-button"
|
||||
@click="editFeatured = true"
|
||||
>
|
||||
<StarIcon aria-hidden="true" />
|
||||
Feature image
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
id="gallery-image-featured"
|
||||
class="iconified-button"
|
||||
@click="editFeatured = false"
|
||||
>
|
||||
<StarIcon fill="currentColor" aria-hidden="true" />
|
||||
Unfeature image
|
||||
</button>
|
||||
<div class="button-group">
|
||||
<button class="iconified-button" @click="$refs.modal_edit_item.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="editIndex === -1"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="shouldPreventActions"
|
||||
@click="createGalleryItem"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add gallery image
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="iconified-button brand-button"
|
||||
:disabled="shouldPreventActions"
|
||||
@click="editGalleryItem"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
v-if="currentMember"
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete this gallery image?"
|
||||
description="This will remove this gallery image forever (like really forever)."
|
||||
:has-to-type="false"
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteGalleryImage"
|
||||
/>
|
||||
<div
|
||||
v-if="expandedGalleryItem != null"
|
||||
class="expanded-image-modal"
|
||||
@click="expandedGalleryItem = null"
|
||||
>
|
||||
<div class="content">
|
||||
<img
|
||||
class="image"
|
||||
:class="{ 'zoomed-in': zoomedIn }"
|
||||
:src="
|
||||
expandedGalleryItem.url
|
||||
? expandedGalleryItem.url
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<div class="floating" @click.stop>
|
||||
<div class="text">
|
||||
<h2 v-if="expandedGalleryItem.title">
|
||||
{{ expandedGalleryItem.title }}
|
||||
</h2>
|
||||
<p v-if="expandedGalleryItem.description">
|
||||
{{ expandedGalleryItem.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="buttons">
|
||||
<button class="close circle-button" @click="expandedGalleryItem = null">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
<a
|
||||
class="open circle-button"
|
||||
target="_blank"
|
||||
:href="
|
||||
expandedGalleryItem.url
|
||||
? expandedGalleryItem.url
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</a>
|
||||
<button class="circle-button" @click="zoomedIn = !zoomedIn">
|
||||
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
|
||||
<ContractIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="project.gallery.length > 1"
|
||||
class="previous circle-button"
|
||||
@click="previousImage()"
|
||||
>
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="project.gallery.length > 1"
|
||||
class="next circle-button"
|
||||
@click="nextImage()"
|
||||
>
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentMember" class="card header-buttons">
|
||||
<FileInput
|
||||
:max-size="524288000"
|
||||
:accept="acceptFileTypes"
|
||||
prompt="Upload an image"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
|
||||
@change="handleFiles"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<span class="indicator">
|
||||
<InfoIcon /> Click to choose an image or drag one onto this page
|
||||
</span>
|
||||
<DropArea
|
||||
:accept="acceptFileTypes"
|
||||
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
|
||||
@change="handleFiles"
|
||||
/>
|
||||
</div>
|
||||
<div class="items">
|
||||
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||
<a class="gallery-thumbnail" @click="expandImage(item, index)">
|
||||
<img
|
||||
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
|
||||
:alt="item.title ? item.title : 'gallery-image'"
|
||||
/>
|
||||
</a>
|
||||
<div class="gallery-body">
|
||||
<div class="gallery-info">
|
||||
<h2 v-if="item.title">
|
||||
{{ item.title }}
|
||||
</h2>
|
||||
<p v-if="item.description">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-bottom">
|
||||
<div class="gallery-created">
|
||||
<CalendarIcon />
|
||||
{{ $dayjs(item.created).format('MMMM D, YYYY') }}
|
||||
</div>
|
||||
<div v-if="currentMember" class="gallery-buttons input-group">
|
||||
<button
|
||||
class="iconified-button"
|
||||
@click="
|
||||
() => {
|
||||
resetEdit()
|
||||
editIndex = index
|
||||
editTitle = item.title
|
||||
editDescription = item.description
|
||||
editFeatured = item.featured
|
||||
editOrder = item.ordering
|
||||
$refs.modal_edit_item.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="iconified-button"
|
||||
@click="
|
||||
() => {
|
||||
deleteIndex = index
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
PlusIcon,
|
||||
CalendarIcon,
|
||||
EditIcon,
|
||||
TrashIcon,
|
||||
SaveIcon,
|
||||
StarIcon,
|
||||
XIcon,
|
||||
RightArrowIcon,
|
||||
LeftArrowIcon,
|
||||
ExternalIcon,
|
||||
ExpandIcon,
|
||||
ContractIcon,
|
||||
UploadIcon,
|
||||
InfoIcon,
|
||||
ImageIcon,
|
||||
TransferIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ConfirmModal } from '@modrinth/ui'
|
||||
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,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const title = `${props.project.title} - Gallery`
|
||||
const description = `View ${props.project.gallery.length} images of ${props.project.title} on Modrinth.`
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default defineNuxtComponent({
|
||||
data() {
|
||||
return {
|
||||
expandedGalleryItem: null,
|
||||
expandedGalleryIndex: 0,
|
||||
zoomedIn: false,
|
||||
|
||||
deleteIndex: -1,
|
||||
|
||||
editIndex: -1,
|
||||
editTitle: '',
|
||||
editDescription: '',
|
||||
editFeatured: false,
|
||||
editOrder: null,
|
||||
editFile: null,
|
||||
previewImage: null,
|
||||
shouldPreventActions: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
acceptFileTypes() {
|
||||
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this._keyListener = function (e) {
|
||||
if (this.expandedGalleryItem) {
|
||||
e.preventDefault()
|
||||
if (e.key === 'Escape') {
|
||||
this.expandedGalleryItem = null
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.previousImage()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
this.nextImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this._keyListener.bind(this))
|
||||
},
|
||||
methods: {
|
||||
nextImage() {
|
||||
this.expandedGalleryIndex++
|
||||
if (this.expandedGalleryIndex >= this.project.gallery.length) {
|
||||
this.expandedGalleryIndex = 0
|
||||
}
|
||||
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
|
||||
},
|
||||
previousImage() {
|
||||
this.expandedGalleryIndex--
|
||||
if (this.expandedGalleryIndex < 0) {
|
||||
this.expandedGalleryIndex = this.project.gallery.length - 1
|
||||
}
|
||||
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
|
||||
},
|
||||
expandImage(item, index) {
|
||||
this.expandedGalleryItem = item
|
||||
this.expandedGalleryIndex = index
|
||||
this.zoomedIn = false
|
||||
},
|
||||
resetEdit() {
|
||||
this.editIndex = -1
|
||||
this.editTitle = ''
|
||||
this.editDescription = ''
|
||||
this.editFeatured = false
|
||||
this.editOrder = null
|
||||
this.editFile = null
|
||||
this.previewImage = null
|
||||
},
|
||||
handleFiles(files) {
|
||||
this.resetEdit()
|
||||
this.editFile = files[0]
|
||||
|
||||
this.showPreviewImage()
|
||||
this.$refs.modal_edit_item.show()
|
||||
},
|
||||
showPreviewImage() {
|
||||
const reader = new FileReader()
|
||||
if (this.editFile instanceof Blob) {
|
||||
reader.readAsDataURL(this.editFile)
|
||||
reader.onload = (event) => {
|
||||
this.previewImage = event.target.result
|
||||
}
|
||||
}
|
||||
},
|
||||
async createGalleryItem() {
|
||||
this.shouldPreventActions = true
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
let url = `project/${this.project.id}/gallery?ext=${
|
||||
this.editFile
|
||||
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
|
||||
: null
|
||||
}&featured=${this.editFeatured}`
|
||||
|
||||
if (this.editTitle) {
|
||||
url += `&title=${encodeURIComponent(this.editTitle)}`
|
||||
}
|
||||
if (this.editDescription) {
|
||||
url += `&description=${encodeURIComponent(this.editDescription)}`
|
||||
}
|
||||
if (this.editOrder) {
|
||||
url += `&ordering=${this.editOrder}`
|
||||
}
|
||||
|
||||
await useBaseFetch(url, {
|
||||
method: 'POST',
|
||||
body: this.editFile,
|
||||
})
|
||||
await this.resetProject()
|
||||
|
||||
this.$refs.modal_edit_item.hide()
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
this.shouldPreventActions = false
|
||||
},
|
||||
async editGalleryItem() {
|
||||
this.shouldPreventActions = true
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
|
||||
this.project.gallery[this.editIndex].url
|
||||
)}&featured=${this.editFeatured}`
|
||||
|
||||
if (this.editTitle) {
|
||||
url += `&title=${encodeURIComponent(this.editTitle)}`
|
||||
}
|
||||
if (this.editDescription) {
|
||||
url += `&description=${encodeURIComponent(this.editDescription)}`
|
||||
}
|
||||
if (this.editOrder) {
|
||||
url += `&ordering=${this.editOrder}`
|
||||
}
|
||||
|
||||
await useBaseFetch(url, {
|
||||
method: 'PATCH',
|
||||
})
|
||||
|
||||
await this.resetProject()
|
||||
this.$refs.modal_edit_item.hide()
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
this.shouldPreventActions = false
|
||||
},
|
||||
async deleteGalleryImage() {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
await useBaseFetch(
|
||||
`project/${this.project.id}/gallery?url=${encodeURIComponent(
|
||||
this.project.gallery[this.deleteIndex].url
|
||||
)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
await this.resetProject()
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.indicator {
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
align-items: center;
|
||||
color: var(--color-text-inactive);
|
||||
}
|
||||
}
|
||||
|
||||
.expanded-image-modal {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
overflow: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000000;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
width: calc(100vw - 2 * var(--spacing-card-lg));
|
||||
height: calc(100vh - 2 * var(--spacing-card-lg));
|
||||
|
||||
.circle-button {
|
||||
padding: 0.5rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
max-width: 2rem;
|
||||
color: var(--color-button-text);
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--size-rounded-max);
|
||||
margin: 0;
|
||||
box-shadow: inset 0px -1px 1px rgb(17 24 39 / 10%);
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-button-bg-hover) !important;
|
||||
|
||||
svg {
|
||||
color: var(--color-button-text-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-button-bg-active) !important;
|
||||
|
||||
svg {
|
||||
color: var(--color-button-text-active) !important;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: calc(100vw - 2 * var(--spacing-card-lg));
|
||||
max-height: calc(100vh - 2 * var(--spacing-card-lg));
|
||||
border-radius: var(--size-rounded-card);
|
||||
|
||||
&.zoomed-in {
|
||||
object-fit: cover;
|
||||
width: auto;
|
||||
height: calc(100vh - 2 * var(--spacing-card-lg));
|
||||
max-width: calc(100vw - 2 * var(--spacing-card-lg));
|
||||
}
|
||||
}
|
||||
.floating {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: var(--spacing-card-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-sm);
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
opacity: 1;
|
||||
padding: 2rem 2rem 0 2rem;
|
||||
|
||||
&:not(&:hover) {
|
||||
opacity: 0.4;
|
||||
.text {
|
||||
transform: translateY(2.5rem) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
.controls {
|
||||
transform: translateY(0.25rem) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 40rem;
|
||||
transition: opacity 0.25s ease-in-out, transform 0.25s ease-in-out;
|
||||
text-shadow: 1px 1px 10px #000000d4;
|
||||
margin-bottom: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
h2 {
|
||||
color: var(--dark-color-text-dark);
|
||||
font-size: 1.25rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--dark-color-text);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.controls {
|
||||
background-color: var(--color-raised-bg);
|
||||
padding: var(--spacing-card-md);
|
||||
border-radius: var(--size-rounded-card);
|
||||
transition: opacity 0.25s ease-in-out, transform 0.25s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-gap: var(--spacing-card-md);
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
|
||||
|
||||
min-height: 10rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.gallery-body {
|
||||
flex-grow: 1;
|
||||
width: calc(100% - 2 * var(--spacing-card-md));
|
||||
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
||||
|
||||
.gallery-info {
|
||||
h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-thumbnail {
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
transition: filter 0.25s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-bottom {
|
||||
width: calc(100% - 2 * var(--spacing-card-md));
|
||||
padding: 0 var(--spacing-card-md) var(--spacing-card-sm) var(--spacing-card-md);
|
||||
|
||||
.gallery-created {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-icon);
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.columns {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-gallery {
|
||||
padding: var(--spacing-card-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.gallery-file-input {
|
||||
.file-header {
|
||||
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
padding: var(--spacing-card-md);
|
||||
|
||||
svg {
|
||||
min-width: 1rem;
|
||||
}
|
||||
strong {
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
.iconified-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 0 0 var(--size-rounded-card) var(--size-rounded-card);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 15rem;
|
||||
object-fit: contain;
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
apps/frontend/src/pages/[type]/[id]/index.vue
Normal file
23
apps/frontend/src/pages/[type]/[id]/index.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="project.body"
|
||||
class="markdown-body card"
|
||||
v-html="renderHighlightedString(project.body || '')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { renderHighlightedString } from '~/helpers/highlight.js'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: { renderHighlightedString },
|
||||
})
|
||||
</script>
|
||||
216
apps/frontend/src/pages/[type]/[id]/moderation.vue
Normal file
216
apps/frontend/src/pages/[type]/[id]/moderation.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Project status</h2>
|
||||
<Badge :type="project.status" />
|
||||
<p v-if="isApproved(project)">
|
||||
Your project has been approved by the moderators and you may freely change project
|
||||
visibility in
|
||||
<router-link :to="`${getProjectLink(project)}/settings`" class="text-link"
|
||||
>your project's settings</router-link
|
||||
>.
|
||||
</p>
|
||||
<div v-else-if="isUnderReview(project)">
|
||||
<p>
|
||||
Modrinth's team of content moderators work hard to review all submitted projects.
|
||||
Typically, you can expect a new project to be reviewed within 24 to 48 hours. Please keep
|
||||
in mind that larger projects, especially modpacks, may require more time to review.
|
||||
Certain holidays or events may also lead to delays depending on moderator availability.
|
||||
Modrinth's moderators will leave a message below if they have any questions or concerns
|
||||
for you.
|
||||
</p>
|
||||
<p>
|
||||
If your review has taken more than 48 hours, check our
|
||||
<a
|
||||
class="text-link"
|
||||
href="https://support.modrinth.com/en/articles/8793355-modrinth-project-review-times"
|
||||
target="_blank"
|
||||
>
|
||||
support article on review times
|
||||
</a>
|
||||
for moderation delays.
|
||||
</p>
|
||||
</div>
|
||||
<template v-else-if="isRejected(project)">
|
||||
<p>
|
||||
Your project does not currently meet Modrinth's
|
||||
<nuxt-link to="/legal/rules" class="text-link" target="_blank">content rules</nuxt-link>
|
||||
and the moderators have requested you make changes before it can be approved. Read the
|
||||
messages from the moderators below and address their comments before resubmitting.
|
||||
</p>
|
||||
<p class="warning">
|
||||
<IssuesIcon /> Repeated submissions without addressing the moderators' comments may result
|
||||
in an account suspension.
|
||||
</p>
|
||||
</template>
|
||||
<h3>Current visibility</h3>
|
||||
<ul class="visibility-info">
|
||||
<li v-if="isListed(project)">
|
||||
<CheckIcon class="good" />
|
||||
Listed in search results
|
||||
</li>
|
||||
<li v-else>
|
||||
<ExitIcon class="bad" />
|
||||
Not listed in search results
|
||||
</li>
|
||||
<li v-if="isListed(project)">
|
||||
<CheckIcon class="good" />
|
||||
Listed on the profiles of members
|
||||
</li>
|
||||
<li v-else>
|
||||
<ExitIcon class="bad" />
|
||||
Not listed on the profiles of members
|
||||
</li>
|
||||
<li v-if="isPrivate(project)">
|
||||
<ExitIcon class="bad" />
|
||||
Not accessible with a direct link
|
||||
</li>
|
||||
<li v-else>
|
||||
<CheckIcon class="good" />
|
||||
Accessible with a direct link
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="messages" class="universal-card">
|
||||
<h2>Messages</h2>
|
||||
<p>
|
||||
This is a private conversation thread with the Modrinth moderators. They may message you
|
||||
with issues concerning this project. This thread is only checked when you submit your
|
||||
project for review. For additional inquiries, contact
|
||||
<a href="https://support.modrinth.com">Modrinth support</a>.
|
||||
</p>
|
||||
<ConversationThread
|
||||
v-if="thread"
|
||||
:thread="thread"
|
||||
:project="project"
|
||||
:set-status="setStatus"
|
||||
:current-member="currentMember"
|
||||
:auth="auth"
|
||||
@update-thread="(newThread) => (thread = newThread)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ExitIcon, CheckIcon, IssuesIcon } from '@modrinth/assets'
|
||||
import { Badge } from '@modrinth/ui'
|
||||
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
||||
import {
|
||||
getProjectLink,
|
||||
isApproved,
|
||||
isListed,
|
||||
isPrivate,
|
||||
isRejected,
|
||||
isUnderReview,
|
||||
} from '~/helpers/projects.js'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: thread } = await useAsyncData(`thread/${props.project.thread_id}`, () =>
|
||||
useBaseFetch(`thread/${props.project.thread_id}`)
|
||||
)
|
||||
async function setStatus(status) {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
const data = {}
|
||||
data.status = status
|
||||
await useBaseFetch(`project/${props.project.id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
|
||||
const project = props.project
|
||||
project.status = status
|
||||
await props.resetProject()
|
||||
thread.value = await useBaseFetch(`thread/${thread.value.id}`)
|
||||
} catch (err) {
|
||||
app.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
:deep(.badge) {
|
||||
display: contents;
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-error {
|
||||
.code {
|
||||
margin-top: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-info {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
&.good {
|
||||
color: var(--color-brand-green);
|
||||
}
|
||||
|
||||
&.bad {
|
||||
color: var(--color-red);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-orange);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
34
apps/frontend/src/pages/[type]/[id]/settings/analytics.vue
Normal file
34
apps/frontend/src/pages/[type]/[id]/settings/analytics.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="universal-card">
|
||||
<h2>Analytics</h2>
|
||||
|
||||
<p>
|
||||
This page shows you the analytics for your project, <strong>{{ project.title }}</strong
|
||||
>. You can see the number of downloads, page views and revenue earned for your project, as
|
||||
well as the total downloads and page views for {{ project.title }} by country.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChartDisplay :projects="[props.project]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.markdown-body {
|
||||
margin-bottom: var(--gap-md);
|
||||
}
|
||||
</style>
|
||||
131
apps/frontend/src/pages/[type]/[id]/settings/description.vue
Normal file
131
apps/frontend/src/pages/[type]/[id]/settings/description.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="universal-card">
|
||||
<div class="markdown-disclaimer">
|
||||
<h2>Description</h2>
|
||||
<span class="label__description">
|
||||
You can type an extended description of your mod here.
|
||||
<span class="label__subdescription">
|
||||
The description must clearly and honestly describe the purpose and function of the
|
||||
project. See section 2.1 of the
|
||||
<nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link>
|
||||
for the full requirements.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownEditor
|
||||
v-model="description"
|
||||
:on-image-upload="onUploadHandler"
|
||||
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
|
||||
/>
|
||||
<div class="input-group markdown-disclaimer">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { MarkdownEditor } from '@modrinth/ui'
|
||||
import Chips from '~/components/ui/Chips.vue'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg?component'
|
||||
import { renderHighlightedString } from '~/helpers/highlight.js'
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Chips,
|
||||
SaveIcon,
|
||||
MarkdownEditor,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
allMembers: {
|
||||
type: Array,
|
||||
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',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
description: this.project.body,
|
||||
bodyViewMode: 'source',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
patchData() {
|
||||
const data = {}
|
||||
|
||||
if (this.description !== this.project.body) {
|
||||
data.body = this.description
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
hasChanges() {
|
||||
return Object.keys(this.patchData).length > 0
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.EDIT_BODY = 1 << 3
|
||||
},
|
||||
methods: {
|
||||
renderHighlightedString,
|
||||
saveChanges() {
|
||||
if (this.hasChanges) {
|
||||
this.patchProject(this.patchData)
|
||||
}
|
||||
},
|
||||
async onUploadHandler(file) {
|
||||
const response = await useImageUpload(file, {
|
||||
context: 'project',
|
||||
projectID: this.project.id,
|
||||
})
|
||||
return response.url
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-disclaimer {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
.universal-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
440
apps/frontend/src/pages/[type]/[id]/settings/index.vue
Normal file
440
apps/frontend/src/pages/[type]/[id]/settings/index.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete this project?"
|
||||
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
|
||||
:has-to-type="true"
|
||||
:confirmation-text="project.title"
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteProject"
|
||||
/>
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Project 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 : project.icon_url"
|
||||
:alt="project.title"
|
||||
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="choose-image iconified-button"
|
||||
prompt="Upload icon"
|
||||
:disabled="!hasPermission"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<button
|
||||
v-if="!deletedIcon && (previewImage || project.icon_url)"
|
||||
class="iconified-button"
|
||||
: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/{{ $getProjectTypeForUrl(project.project_type, project.loaders) }}/
|
||||
</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>
|
||||
<template
|
||||
v-if="
|
||||
project.versions?.length !== 0 &&
|
||||
project.project_type !== 'resourcepack' &&
|
||||
project.project_type !== 'plugin' &&
|
||||
project.project_type !== 'shader' &&
|
||||
project.project_type !== 'datapack'
|
||||
"
|
||||
>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-client">
|
||||
<span class="label__title">Client-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
client side. Just because a mod works in Singleplayer doesn't mean it has actual
|
||||
client-side functionality.
|
||||
</span>
|
||||
</label>
|
||||
<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)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-server">
|
||||
<span class="label__title">Server-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
|
||||
server.
|
||||
</span>
|
||||
</label>
|
||||
<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)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-visibility">
|
||||
<span class="label__title">Visibility</span>
|
||||
<div class="label__description">
|
||||
Listed and archived projects are visible in search. Unlisted projects are published, but
|
||||
not visible in search or on user profiles. Private projects are only accessible by
|
||||
members of the project.
|
||||
|
||||
<p>If approved by the moderators:</p>
|
||||
<ul class="visibility-info">
|
||||
<li>
|
||||
<CheckIcon
|
||||
v-if="visibility === 'approved' || visibility === 'archived'"
|
||||
class="good"
|
||||
/>
|
||||
<ExitIcon v-else class="bad" />
|
||||
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible in search
|
||||
</li>
|
||||
<li>
|
||||
<ExitIcon
|
||||
v-if="visibility === 'unlisted' || visibility === 'private'"
|
||||
class="bad"
|
||||
/>
|
||||
<CheckIcon v-else class="good" />
|
||||
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible on profile
|
||||
</li>
|
||||
<li>
|
||||
<CheckIcon v-if="visibility !== 'private'" class="good" />
|
||||
<IssuesIcon
|
||||
v-else
|
||||
v-tooltip="{
|
||||
content:
|
||||
visibility === 'private'
|
||||
? 'Only members will be able to view the project.'
|
||||
: '',
|
||||
}"
|
||||
class="warn"
|
||||
/>
|
||||
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible via URL
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-visibility"
|
||||
v-model="visibility"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="tags.approvedStatuses"
|
||||
:custom-label="(value) => $formatProjectStatus(value)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Delete project</span>
|
||||
</h3>
|
||||
</div>
|
||||
<p>
|
||||
Removes your project from Modrinth's servers and search. Clicking on this will delete your
|
||||
project, so be extra careful!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
:disabled="!hasDeletePermission"
|
||||
@click="$refs.modal_confirm.show()"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete project
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
import UploadIcon from '~/assets/images/utils/upload.svg?component'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg?component'
|
||||
import TrashIcon from '~/assets/images/utils/trash.svg?component'
|
||||
import ExitIcon from '~/assets/images/utils/x.svg?component'
|
||||
import IssuesIcon from '~/assets/images/utils/issues.svg?component'
|
||||
import CheckIcon from '~/assets/images/utils/check.svg?component'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
patchProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
patchIcon: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const tags = useTags()
|
||||
const router = useNativeRouter()
|
||||
|
||||
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 {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
&.good {
|
||||
color: var(--color-brand-green);
|
||||
}
|
||||
|
||||
&.bad {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-input {
|
||||
min-height: 8rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.small-multiselect {
|
||||
max-width: 15rem;
|
||||
}
|
||||
</style>
|
||||
297
apps/frontend/src/pages/[type]/[id]/settings/license.vue
Normal file
297
apps/frontend/src/pages/[type]/[id]/settings/license.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<div class="adjacent-input">
|
||||
<label for="license-multiselect">
|
||||
<span class="label__title size-card-header">License</span>
|
||||
<span class="label__description">
|
||||
It is very important to choose a proper license for your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. You may choose one from
|
||||
our list or provide a custom license. You may also provide a custom URL to your chosen
|
||||
license; otherwise, the license text will be displayed.
|
||||
<span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
|
||||
Enter a valid
|
||||
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
|
||||
SPDX license identifier</a
|
||||
>
|
||||
in the marked area. If your license does not have a SPDX identifier (for example, if
|
||||
you created the license yourself or if the license is Minecraft-specific), simply
|
||||
check the box and enter the name of the license instead.
|
||||
</span>
|
||||
<span class="label__subdescription">
|
||||
Confused? See our
|
||||
<a
|
||||
href="https://blog.modrinth.com/licensing-guide/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link"
|
||||
>
|
||||
licensing guide</a
|
||||
>
|
||||
for more information.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="input-stack">
|
||||
<Multiselect
|
||||
id="license-multiselect"
|
||||
v-model="license"
|
||||
placeholder="Select license..."
|
||||
track-by="short"
|
||||
label="friendly"
|
||||
:options="defaultLicenses"
|
||||
:searchable="true"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:class="{
|
||||
'known-error': license?.short === '' && showKnownErrors,
|
||||
}"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="license?.requiresOnlyOrLater"
|
||||
v-model="allowOrLater"
|
||||
:disabled="!hasPermission"
|
||||
description="Allow later editions of this license"
|
||||
>
|
||||
Allow later editions of this license
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
v-if="license?.friendly === 'Custom'"
|
||||
v-model="nonSpdxLicense"
|
||||
:disabled="!hasPermission"
|
||||
description="License does not have a SPDX identifier"
|
||||
>
|
||||
License does not have a SPDX identifier
|
||||
</Checkbox>
|
||||
<input
|
||||
v-if="license?.friendly === 'Custom'"
|
||||
v-model="license.short"
|
||||
type="text"
|
||||
maxlength="2048"
|
||||
:placeholder="nonSpdxLicense ? 'License name' : 'SPDX identifier'"
|
||||
:class="{
|
||||
'known-error': license.short === '' && showKnownErrors,
|
||||
}"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<input
|
||||
v-model="licenseUrl"
|
||||
type="url"
|
||||
maxlength="2048"
|
||||
placeholder="License URL (optional)"
|
||||
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-stack">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges || license === null"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Multiselect from 'vue-multiselect'
|
||||
import Checkbox from '~/components/ui/Checkbox'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg?component'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Multiselect,
|
||||
Checkbox,
|
||||
SaveIcon,
|
||||
},
|
||||
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',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
licenseUrl: '',
|
||||
license: { friendly: '', short: '', requiresOnlyOrLater: false },
|
||||
allowOrLater: this.project.license.id.includes('-or-later'),
|
||||
nonSpdxLicense: this.project.license.id.includes('LicenseRef-'),
|
||||
showKnownErrors: false,
|
||||
}
|
||||
},
|
||||
async setup(props) {
|
||||
const defaultLicenses = shallowRef([
|
||||
{ friendly: 'Custom', short: '' },
|
||||
{
|
||||
friendly: 'All Rights Reserved/No License',
|
||||
short: 'All-Rights-Reserved',
|
||||
},
|
||||
{ friendly: 'Apache License 2.0', short: 'Apache-2.0' },
|
||||
{
|
||||
friendly: 'BSD 2-Clause "Simplified" License',
|
||||
short: 'BSD-2-Clause',
|
||||
},
|
||||
{
|
||||
friendly: 'BSD 3-Clause "New" or "Revised" License',
|
||||
short: 'BSD-3-Clause',
|
||||
},
|
||||
{
|
||||
friendly: 'CC Zero (Public Domain equivalent)',
|
||||
short: 'CC0-1.0',
|
||||
},
|
||||
{ friendly: 'CC-BY 4.0', short: 'CC-BY-4.0' },
|
||||
{
|
||||
friendly: 'CC-BY-SA 4.0',
|
||||
short: 'CC-BY-SA-4.0',
|
||||
},
|
||||
{
|
||||
friendly: 'CC-BY-NC 4.0',
|
||||
short: 'CC-BY-NC-4.0',
|
||||
},
|
||||
{
|
||||
friendly: 'CC-BY-NC-SA 4.0',
|
||||
short: 'CC-BY-NC-SA-4.0',
|
||||
},
|
||||
{
|
||||
friendly: 'CC-BY-ND 4.0',
|
||||
short: 'CC-BY-ND-4.0',
|
||||
},
|
||||
{
|
||||
friendly: 'CC-BY-NC-ND 4.0',
|
||||
short: 'CC-BY-NC-ND-4.0',
|
||||
},
|
||||
{
|
||||
friendly: 'GNU Affero General Public License v3',
|
||||
short: 'AGPL-3.0',
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: 'GNU Lesser General Public License v2.1',
|
||||
short: 'LGPL-2.1',
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: 'GNU Lesser General Public License v3',
|
||||
short: 'LGPL-3.0',
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: 'GNU General Public License v2',
|
||||
short: 'GPL-2.0',
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: 'GNU General Public License v3',
|
||||
short: 'GPL-3.0',
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{ friendly: 'ISC License', short: 'ISC' },
|
||||
{ friendly: 'MIT License', short: 'MIT' },
|
||||
{ friendly: 'Mozilla Public License 2.0', short: 'MPL-2.0' },
|
||||
{ friendly: 'zlib License', short: 'Zlib' },
|
||||
])
|
||||
|
||||
const licenseUrl = ref(props.project.license.url)
|
||||
|
||||
const licenseId = props.project.license.id
|
||||
const trimmedLicenseId = licenseId
|
||||
.replaceAll('-only', '')
|
||||
.replaceAll('-or-later', '')
|
||||
.replaceAll('LicenseRef-', '')
|
||||
|
||||
const license = ref(
|
||||
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
|
||||
friendly: 'Custom',
|
||||
short: licenseId.replaceAll('LicenseRef-', ''),
|
||||
}
|
||||
)
|
||||
|
||||
if (licenseId === 'LicenseRef-Unknown') {
|
||||
license.value = {
|
||||
friendly: 'Unknown',
|
||||
short: licenseId.replaceAll('LicenseRef-', ''),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultLicenses,
|
||||
licenseUrl,
|
||||
license,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasPermission() {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
||||
},
|
||||
licenseId() {
|
||||
let id = ''
|
||||
if (this.license === null) return id
|
||||
if (
|
||||
(this.nonSpdxLicense && this.license.friendly === 'Custom') ||
|
||||
this.license.short === 'All-Rights-Reserved' ||
|
||||
this.license.short === 'Unknown'
|
||||
) {
|
||||
id += 'LicenseRef-'
|
||||
}
|
||||
id += this.license.short
|
||||
if (this.license.requiresOnlyOrLater) {
|
||||
id += this.allowOrLater ? '-or-later' : '-only'
|
||||
}
|
||||
if (this.nonSpdxLicense && this.license.friendly === 'Custom') {
|
||||
id = id.replaceAll(' ', '-')
|
||||
}
|
||||
return id
|
||||
},
|
||||
patchData() {
|
||||
const data = {}
|
||||
|
||||
if (this.licenseId !== this.project.license.id) {
|
||||
data.license_id = this.licenseId
|
||||
data.license_url = this.licenseUrl ? this.licenseUrl : null
|
||||
} else if (this.licenseUrl !== this.project.license.url) {
|
||||
data.license_url = this.licenseUrl ? this.licenseUrl : null
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
hasChanges() {
|
||||
return Object.keys(this.patchData).length > 0
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveChanges() {
|
||||
if (this.hasChanges) {
|
||||
this.patchProject(this.patchData)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
271
apps/frontend/src/pages/[type]/[id]/settings/links.vue
Normal file
271
apps/frontend/src/pages/[type]/[id]/settings/links.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>External links</h2>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
id="project-issue-tracker"
|
||||
title="A place for users to report bugs, issues, and concerns about your project."
|
||||
>
|
||||
<span class="label__title">Issue tracker</span>
|
||||
<span class="label__description">
|
||||
A place for users to report bugs, issues, and concerns about your project.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-issue-tracker"
|
||||
v-model="issuesUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
id="project-source-code"
|
||||
title="A page/repository containing the source code for your project"
|
||||
>
|
||||
<span class="label__title">Source code</span>
|
||||
<span class="label__description">
|
||||
A page/repository containing the source code for your project
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-source-code"
|
||||
v-model="sourceUrl"
|
||||
type="url"
|
||||
maxlength="2048"
|
||||
placeholder="Enter a valid URL"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
id="project-wiki-page"
|
||||
title="A page containing information, documentation, and help for the project."
|
||||
>
|
||||
<span class="label__title">Wiki page</span>
|
||||
<span class="label__description">
|
||||
A page containing information, documentation, and help for the project.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-wiki-page"
|
||||
v-model="wikiUrl"
|
||||
type="url"
|
||||
maxlength="2048"
|
||||
placeholder="Enter a valid URL"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="project-discord-invite" title="An invitation link to your Discord server.">
|
||||
<span class="label__title">Discord invite</span>
|
||||
<span class="label__description"> An invitation link to your Discord server. </span>
|
||||
</label>
|
||||
<input
|
||||
id="project-discord-invite"
|
||||
v-model="discordUrl"
|
||||
type="url"
|
||||
maxlength="2048"
|
||||
placeholder="Enter a valid URL"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<span class="label">
|
||||
<span class="label__title">Donation links</span>
|
||||
<span class="label__description">
|
||||
Add donation links for users to support you directly.
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-for="(donationLink, index) in donationLinks"
|
||||
:key="`donation-link-${index}`"
|
||||
class="input-group donation-link-group"
|
||||
>
|
||||
<input
|
||||
v-model="donationLink.url"
|
||||
type="url"
|
||||
maxlength="2048"
|
||||
placeholder="Enter a valid URL"
|
||||
:disabled="!hasPermission"
|
||||
@input="updateDonationLinks"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-model="donationLink.id"
|
||||
name="Donation platform selector"
|
||||
:options="tags.donationPlatforms.map((x) => x.short)"
|
||||
:display-name="
|
||||
(option) => tags.donationPlatforms.find((platform) => platform.short === option)?.name
|
||||
"
|
||||
placeholder="Select platform"
|
||||
render-up
|
||||
class="platform-selector"
|
||||
@update:model-value="updateDonationLinks"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownSelect } from '@modrinth/ui'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg?component'
|
||||
|
||||
const tags = useTags()
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
patchProject: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const issuesUrl = ref(props.project.issues_url)
|
||||
const sourceUrl = ref(props.project.source_url)
|
||||
const wikiUrl = ref(props.project.wiki_url)
|
||||
const discordUrl = ref(props.project.discord_url)
|
||||
|
||||
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls))
|
||||
rawDonationLinks.push({
|
||||
id: null,
|
||||
platform: null,
|
||||
url: null,
|
||||
})
|
||||
const donationLinks = ref(rawDonationLinks)
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return (props.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
||||
})
|
||||
|
||||
const patchData = computed(() => {
|
||||
const data = {}
|
||||
|
||||
if (checkDifference(issuesUrl.value, props.project.issues_url)) {
|
||||
data.issues_url = issuesUrl.value === '' ? null : issuesUrl.value.trim()
|
||||
}
|
||||
if (checkDifference(sourceUrl.value, props.project.source_url)) {
|
||||
data.source_url = sourceUrl.value === '' ? null : sourceUrl.value.trim()
|
||||
}
|
||||
if (checkDifference(wikiUrl.value, props.project.wiki_url)) {
|
||||
data.wiki_url = wikiUrl.value === '' ? null : wikiUrl.value.trim()
|
||||
}
|
||||
if (checkDifference(discordUrl.value, props.project.discord_url)) {
|
||||
data.discord_url = discordUrl.value === '' ? null : discordUrl.value.trim()
|
||||
}
|
||||
|
||||
const validDonationLinks = donationLinks.value.filter((link) => link.url && link.id)
|
||||
|
||||
if (
|
||||
validDonationLinks !== props.project.donation_urls &&
|
||||
!(
|
||||
props.project.donation_urls &&
|
||||
props.project.donation_urls.length === 0 &&
|
||||
validDonationLinks.length === 0
|
||||
)
|
||||
) {
|
||||
data.donation_urls = validDonationLinks
|
||||
}
|
||||
|
||||
if (data.donation_urls) {
|
||||
data.donation_urls.forEach((link) => {
|
||||
const platform = tags.value.donationPlatforms.find((platform) => platform.short === link.id)
|
||||
link.platform = platform.name
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(patchData.value).length > 0
|
||||
})
|
||||
|
||||
async function saveChanges() {
|
||||
if (patchData.value && (await props.patchProject(patchData.value))) {
|
||||
donationLinks.value = JSON.parse(JSON.stringify(props.project.donation_urls))
|
||||
donationLinks.value.push({
|
||||
id: null,
|
||||
platform: null,
|
||||
url: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function updateDonationLinks() {
|
||||
const links = donationLinks.value
|
||||
links.forEach((link) => {
|
||||
if (link.url) {
|
||||
const url = link.url.toLowerCase()
|
||||
if (url.includes('patreon.com')) {
|
||||
link.id = 'patreon'
|
||||
} else if (url.includes('ko-fi.com')) {
|
||||
link.id = 'ko-fi'
|
||||
} else if (url.includes('paypal.com') || url.includes('paypal.me')) {
|
||||
link.id = 'paypal'
|
||||
} else if (url.includes('buymeacoffee.com') || url.includes('buymeacoff.ee')) {
|
||||
link.id = 'bmac'
|
||||
} else if (url.includes('github.com/sponsors')) {
|
||||
link.id = 'github'
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!links.find((link) => !(link.url && link.id))) {
|
||||
links.push({
|
||||
id: null,
|
||||
platform: null,
|
||||
url: null,
|
||||
})
|
||||
}
|
||||
donationLinks.value = links
|
||||
}
|
||||
function checkDifference(newLink, existingLink) {
|
||||
if (newLink === '' && existingLink !== null) {
|
||||
return true
|
||||
}
|
||||
if (!newLink && !existingLink) {
|
||||
return false
|
||||
}
|
||||
return newLink !== existingLink
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.donation-link-group {
|
||||
input {
|
||||
flex-grow: 2;
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
:deep(.animated-dropdown .selected) {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
963
apps/frontend/src/pages/[type]/[id]/settings/members.vue
Normal file
963
apps/frontend/src/pages/[type]/[id]/settings/members.vue
Normal file
@@ -0,0 +1,963 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
ref="modal_remove"
|
||||
title="Are you sure you want to remove this project from the organization?"
|
||||
description="If you proceed, this project will no longer be managed by the organization."
|
||||
proceed-label="Remove"
|
||||
:noblur="!(cosmetics?.advancedRendering ?? true)"
|
||||
@proceed="onRemoveFromOrg"
|
||||
/>
|
||||
<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
|
||||
project.
|
||||
</span>
|
||||
</span>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="username"
|
||||
v-model="currentUsername"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
:disabled="(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
|
||||
@keypress.enter="inviteTeamMember()"
|
||||
/>
|
||||
<label for="username" class="hidden">Username</label>
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
:disabled="(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
|
||||
@click="inviteTeamMember()"
|
||||
>
|
||||
<UserPlusIcon />
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<span class="label">
|
||||
<span class="label__title">Leave project</span>
|
||||
<span class="label__description"> Remove yourself as a member of this project. </span>
|
||||
</span>
|
||||
<button
|
||||
class="iconified-button danger-button"
|
||||
:disabled="props.currentMember?.is_owner"
|
||||
:title="
|
||||
props.currentMember?.is_owner
|
||||
? 'You cannot leave the project if you are the owner!'
|
||||
: ''
|
||||
"
|
||||
@click="leaveProject()"
|
||||
>
|
||||
<UserRemoveIcon />
|
||||
Leave project
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
<div
|
||||
v-for="(member, index) in allTeamMembers"
|
||||
:key="member.user.id"
|
||||
class="universal-card member"
|
||||
:class="{ open: openTeamMembers.includes(member.user.id) }"
|
||||
>
|
||||
<div class="member-header">
|
||||
<div class="info">
|
||||
<Avatar :src="member.avatar_url" :alt="member.username" size="sm" circle />
|
||||
<div class="text">
|
||||
<nuxt-link :to="'/user/' + member.user.username" class="name">
|
||||
<p>{{ member.name }}</p>
|
||||
<CrownIcon v-if="member.is_owner" v-tooltip="'Project owner'" />
|
||||
</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
|
||||
class="square-button 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 class="adjacent-input">
|
||||
<label :for="`member-${allTeamMembers[index].user.username}-role`">
|
||||
<span class="label__title">Role</span>
|
||||
<span class="label__description">
|
||||
The title of the role that this member plays for this project.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`member-${allTeamMembers[index].user.username}-role`"
|
||||
v-model="allTeamMembers[index].role"
|
||||
type="text"
|
||||
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label :for="`member-${allTeamMembers[index].user.username}-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
|
||||
this project's revenue goes to this member.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`member-${allTeamMembers[index].user.username}-monetization-weight`"
|
||||
v-model="allTeamMembers[index].payouts_split"
|
||||
type="number"
|
||||
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="!member.is_owner">
|
||||
<span class="label">
|
||||
<span class="label__title">Permissions</span>
|
||||
</span>
|
||||
<div v-if="allTeamMembers[index]" class="permissions">
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
|
||||
"
|
||||
label="Upload version"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & DELETE_VERSION) === DELETE_VERSION"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION
|
||||
"
|
||||
label="Delete version"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= DELETE_VERSION"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & EDIT_DETAILS) === EDIT_DETAILS"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS
|
||||
"
|
||||
label="Edit details"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= EDIT_DETAILS"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & EDIT_BODY) === EDIT_BODY"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & EDIT_BODY) !== EDIT_BODY
|
||||
"
|
||||
label="Edit body"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= EDIT_BODY"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & MANAGE_INVITES) === MANAGE_INVITES"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES
|
||||
"
|
||||
label="Manage invites"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= MANAGE_INVITES"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
|
||||
"
|
||||
label="Remove member"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & EDIT_MEMBER) === EDIT_MEMBER"
|
||||
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
label="Edit member"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= EDIT_MEMBER"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & DELETE_PROJECT) === DELETE_PROJECT"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT
|
||||
"
|
||||
label="Delete project"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= DELETE_PROJECT"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
|
||||
"
|
||||
label="View analytics"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
|
||||
"
|
||||
label="View revenue"
|
||||
@update:model-value="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
@click="updateTeamMember(index)"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
<button
|
||||
v-if="!member.is_owner"
|
||||
class="iconified-button danger-button"
|
||||
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
@click="removeTeamMember(index)"
|
||||
>
|
||||
<UserRemoveIcon />
|
||||
Remove member
|
||||
</button>
|
||||
<button
|
||||
v-if="!member.is_owner && props.currentMember?.is_owner && member.accepted"
|
||||
class="iconified-button"
|
||||
@click="transferOwnership(index)"
|
||||
>
|
||||
<TransferIcon />
|
||||
Transfer ownership
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<span class="label__title size-card-header">Organization</span>
|
||||
</div>
|
||||
<div v-if="props.organization">
|
||||
<p>
|
||||
This project is managed by {{ props.organization.name }}. The defaults for member
|
||||
permissions are set in the
|
||||
<nuxt-link :to="`/organization/${props.organization.slug}/settings/members`">
|
||||
organization settings</nuxt-link
|
||||
>. You may override them below.
|
||||
</p>
|
||||
<nuxt-link
|
||||
:to="`/organization/${props.organization.slug}`"
|
||||
class="universal-card button-base recessed org"
|
||||
>
|
||||
<Avatar :src="props.organization.icon_url" :alt="props.organization.name" size="md" />
|
||||
<div class="details">
|
||||
<div class="title">
|
||||
{{ props.organization.name }}
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ props.organization.description }}
|
||||
</div>
|
||||
<span class="stat-bar">
|
||||
<div class="stats">
|
||||
<UsersIcon />
|
||||
<span>
|
||||
{{ acceptedOrgMembers.length }} member<template
|
||||
v-if="acceptedOrgMembers.length !== 1"
|
||||
>s</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<p v-else>
|
||||
This project is not managed by an organization. If you are the member of any organizations,
|
||||
you can transfer management to one of them.
|
||||
</p>
|
||||
<div v-if="!props.organization" class="input-group">
|
||||
<Multiselect
|
||||
id="organization-picker"
|
||||
v-model="selectedOrganization"
|
||||
class="large-multiselect"
|
||||
track-by="id"
|
||||
label="name"
|
||||
open-direction="top"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:options="organizations || []"
|
||||
:disabled="!props.currentMember?.is_owner || organizations?.length === 0"
|
||||
/>
|
||||
<button class="btn btn-primary" :disabled="!selectedOrganization" @click="onAddToOrg">
|
||||
<CheckIcon />
|
||||
Transfer management
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="props.organization" class="btn" @click="$refs.modal_remove.show()">
|
||||
<OrganizationIcon />
|
||||
Remove from organization
|
||||
</button>
|
||||
</section>
|
||||
<div
|
||||
v-for="(member, index) in allOrgMembers"
|
||||
:key="member.user.id"
|
||||
class="universal-card member"
|
||||
: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>
|
||||
<CrownIcon v-if="member.is_owner" v-tooltip="'Organization owner'" />
|
||||
</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
|
||||
class="square-button 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 class="adjacent-input">
|
||||
<label :for="`member-${allOrgMembers[index].user.username}-override-perms`">
|
||||
<span class="label__title">Override values</span>
|
||||
<span class="label__description">
|
||||
Override organization default values and assign custom permissions, roles, and
|
||||
monetization weights to this user on the project.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`member-${allOrgMembers[index].user.username}-override-perms`"
|
||||
v-model="allOrgMembers[index].override"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label :for="`member-${allOrgMembers[index].user.username}-role`">
|
||||
<span class="label__title">Role</span>
|
||||
<span class="label__description">
|
||||
The title of the role that this member plays for this project.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`member-${allOrgMembers[index].user.username}-role`"
|
||||
v-model="allOrgMembers[index].role"
|
||||
type="text"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label :for="`member-${allOrgMembers[index].user.username}-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
|
||||
this project's revenue goes to this member.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`member-${allOrgMembers[index].user.username}-monetization-weight`"
|
||||
v-model="allOrgMembers[index].payouts_split"
|
||||
type="number"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="!member.is_owner">
|
||||
<span class="label">
|
||||
<span class="label__title">Permissions</span>
|
||||
</span>
|
||||
<div class="permissions">
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Upload version"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= UPLOAD_VERSION"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & DELETE_VERSION) === DELETE_VERSION"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & DELETE_VERSION) !== DELETE_VERSION ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Delete version"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= DELETE_VERSION"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & EDIT_DETAILS) === EDIT_DETAILS"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & EDIT_DETAILS) !== EDIT_DETAILS ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Edit details"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= EDIT_DETAILS"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & EDIT_BODY) === EDIT_BODY"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & EDIT_BODY) !== EDIT_BODY ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Edit body"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= EDIT_BODY"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & MANAGE_INVITES) === MANAGE_INVITES"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & MANAGE_INVITES) !== MANAGE_INVITES ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Manage invites"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= MANAGE_INVITES"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Remove member"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= REMOVE_MEMBER"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & EDIT_MEMBER) === EDIT_MEMBER"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Edit member"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= EDIT_MEMBER"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & DELETE_PROJECT) === DELETE_PROJECT"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & DELETE_PROJECT) !== DELETE_PROJECT ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="Delete project"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= DELETE_PROJECT"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="View analytics"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= VIEW_ANALYTICS"
|
||||
/>
|
||||
<Checkbox
|
||||
:model-value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
|
||||
:disabled="
|
||||
(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(props.currentMember?.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS ||
|
||||
!allOrgMembers[index].override
|
||||
"
|
||||
label="View revenue"
|
||||
@update:model-value="allOrgMembers[index].permissions ^= VIEW_PAYOUTS"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
@click="updateOrgMember(index)"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
import { TransferIcon, CheckIcon, UsersIcon } from '@modrinth/assets'
|
||||
import { Avatar, Badge, Card, Checkbox } from '@modrinth/ui'
|
||||
|
||||
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
||||
import DropdownIcon from '~/assets/images/utils/dropdown.svg?component'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg?component'
|
||||
import UserPlusIcon from '~/assets/images/utils/user-plus.svg?component'
|
||||
import UserRemoveIcon from '~/assets/images/utils/user-x.svg?component'
|
||||
import OrganizationIcon from '~/assets/images/utils/organization.svg?component'
|
||||
import CrownIcon from '~/assets/images/utils/crown.svg?component'
|
||||
|
||||
import { removeSelfFromTeam } from '~/helpers/teams.js'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
allMembers: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
resetProject: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
resetOrganization: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
resetMembers: {
|
||||
type: Function,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
const cosmetics = useCosmetics()
|
||||
const auth = await useAuth()
|
||||
|
||||
const allTeamMembers = ref([])
|
||||
const allOrgMembers = ref([])
|
||||
|
||||
const acceptedOrgMembers = computed(() => {
|
||||
return props.organization?.members?.filter((x) => x.accepted) || []
|
||||
})
|
||||
|
||||
function initMembers() {
|
||||
const orgMembers = props.organization?.members || []
|
||||
|
||||
const selectedMembersForOrg = orgMembers.map((partialOrgMember) => {
|
||||
const foundMember = props.allMembers.find((tM) => tM.user.id === partialOrgMember.user.id)
|
||||
const returnVal = foundMember ?? partialOrgMember
|
||||
|
||||
// If replacing a partial with a full member, we need to mark as such.
|
||||
returnVal.override = !!foundMember
|
||||
returnVal.oldOverride = !!foundMember
|
||||
|
||||
returnVal.is_owner = partialOrgMember.is_owner
|
||||
|
||||
return returnVal
|
||||
})
|
||||
|
||||
allOrgMembers.value = selectedMembersForOrg
|
||||
|
||||
allTeamMembers.value = props.allMembers.filter(
|
||||
(x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id)
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.allMembers,
|
||||
() => props.organization,
|
||||
() => props.project,
|
||||
() => props.currentMember,
|
||||
],
|
||||
initMembers
|
||||
)
|
||||
initMembers()
|
||||
|
||||
const currentUsername = ref('')
|
||||
const openTeamMembers = ref([])
|
||||
const selectedOrganization = ref(null)
|
||||
|
||||
const { data: organizations } = useAsyncData('organizations', () => {
|
||||
return useBaseFetch('user/' + auth.value?.user.id + '/organizations', {
|
||||
apiVersion: 3,
|
||||
})
|
||||
})
|
||||
|
||||
const UPLOAD_VERSION = 1 << 0
|
||||
const DELETE_VERSION = 1 << 1
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
const EDIT_BODY = 1 << 3
|
||||
const MANAGE_INVITES = 1 << 4
|
||||
const REMOVE_MEMBER = 1 << 5
|
||||
const EDIT_MEMBER = 1 << 6
|
||||
const DELETE_PROJECT = 1 << 7
|
||||
const VIEW_ANALYTICS = 1 << 8
|
||||
const VIEW_PAYOUTS = 1 << 9
|
||||
|
||||
const onAddToOrg = useClientTry(async () => {
|
||||
if (!selectedOrganization.value) return
|
||||
|
||||
await useBaseFetch(`organization/${selectedOrganization.value.id}/projects`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
project_id: props.project.id,
|
||||
}),
|
||||
apiVersion: 3,
|
||||
})
|
||||
|
||||
await updateMembers()
|
||||
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'Project transferred',
|
||||
text: 'Your project has been transferred to the organization.',
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
const onRemoveFromOrg = useClientTry(async () => {
|
||||
if (!props.project.organization || !auth.value?.user?.id) return
|
||||
|
||||
await useBaseFetch(`organization/${props.project.organization}/projects/${props.project.id}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
new_owner: auth.value.user.id,
|
||||
}),
|
||||
apiVersion: 3,
|
||||
})
|
||||
|
||||
await updateMembers()
|
||||
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'Project removed',
|
||||
text: 'Your project has been removed from the organization.',
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
|
||||
const leaveProject = async () => {
|
||||
await removeSelfFromTeam(props.project.team)
|
||||
navigateTo('/dashboard/projects')
|
||||
}
|
||||
|
||||
const inviteTeamMember = async () => {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
const user = await useBaseFetch(`user/${currentUsername.value}`)
|
||||
const data = {
|
||||
user_id: user.id.trim(),
|
||||
}
|
||||
await useBaseFetch(`team/${props.project.team}/members`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
currentUsername.value = ''
|
||||
await updateMembers()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err?.data?.description || err?.message || err || 'Unknown error',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const removeTeamMember = async (index) => {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
await useBaseFetch(
|
||||
`team/${props.project.team}/members/${allTeamMembers.value[index].user.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
await updateMembers()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err?.data?.description || err?.message || err || 'Unknown error',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const updateTeamMember = async (index) => {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
const data = !allTeamMembers.value[index].is_owner
|
||||
? {
|
||||
permissions: allTeamMembers.value[index].permissions,
|
||||
role: allTeamMembers.value[index].role,
|
||||
payouts_split: allTeamMembers.value[index].payouts_split,
|
||||
}
|
||||
: {
|
||||
payouts_split: allTeamMembers.value[index].payouts_split,
|
||||
role: allTeamMembers.value[index].role,
|
||||
}
|
||||
|
||||
await useBaseFetch(
|
||||
`team/${props.project.team}/members/${allTeamMembers.value[index].user.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
}
|
||||
)
|
||||
await updateMembers()
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'Member(s) updated',
|
||||
text: "Your project's member(s) has been updated.",
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err?.data?.description || err?.message || err || 'Unknown error',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const transferOwnership = async (index) => {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
await useBaseFetch(`team/${props.project.team}/owner`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
user_id: allTeamMembers.value[index].user.id,
|
||||
},
|
||||
})
|
||||
await updateMembers()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err?.data?.description || err?.message || err || 'Unknown error',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function updateOrgMember(index) {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
if (allOrgMembers.value[index].override && !allOrgMembers.value[index].oldOverride) {
|
||||
await useBaseFetch(`team/${props.project.team}/members`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
permissions: allOrgMembers.value[index].permissions,
|
||||
role: allOrgMembers.value[index].role,
|
||||
payouts_split: allOrgMembers.value[index].payouts_split,
|
||||
user_id: allOrgMembers.value[index].user.id,
|
||||
},
|
||||
})
|
||||
} else if (!allOrgMembers.value[index].override && allOrgMembers.value[index].oldOverride) {
|
||||
await useBaseFetch(
|
||||
`team/${props.project.team}/members/${allOrgMembers.value[index].user.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
} else {
|
||||
await useBaseFetch(
|
||||
`team/${props.project.team}/members/${allOrgMembers.value[index].user.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
permissions: allOrgMembers.value[index].permissions,
|
||||
role: allOrgMembers.value[index].role,
|
||||
payouts_split: allOrgMembers.value[index].payouts_split,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
await updateMembers()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err?.data?.description || err?.message || err || 'Unknown error',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const updateMembers = async () => {
|
||||
await Promise.all([props.resetProject(), props.resetOrganization(), props.resetMembers()])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.org {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
svg {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
}
|
||||
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(--spacing-card-md);
|
||||
|
||||
.main-info {
|
||||
margin-bottom: var(--spacing-card-lg);
|
||||
}
|
||||
.permissions {
|
||||
margin-bottom: var(--spacing-card-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.large-multiselect {
|
||||
max-width: 24rem;
|
||||
}
|
||||
</style>
|
||||
300
apps/frontend/src/pages/[type]/[id]/settings/tags.vue
Normal file
300
apps/frontend/src/pages/[type]/[id]/settings/tags.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Tags</span>
|
||||
</h3>
|
||||
</div>
|
||||
<p>
|
||||
Accurate tagging is important to help people find your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
|
||||
that apply.
|
||||
</p>
|
||||
<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"><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>
|
||||
</template>
|
||||
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Checkbox from '~/components/ui/Checkbox.vue'
|
||||
import StarIcon from '~/assets/images/utils/star.svg?component'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg?component'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Checkbox,
|
||||
SaveIcon,
|
||||
StarIcon,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
allMembers: {
|
||||
type: Array,
|
||||
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',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedTags: this.$sortedCategories().filter(
|
||||
(x) =>
|
||||
x.project_type === this.project.actualProjectType &&
|
||||
(this.project.categories.includes(x.name) ||
|
||||
this.project.additional_categories.includes(x.name))
|
||||
),
|
||||
featuredTags: this.$sortedCategories().filter(
|
||||
(x) =>
|
||||
x.project_type === this.project.actualProjectType &&
|
||||
this.project.categories.includes(x.name)
|
||||
),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
categoryLists() {
|
||||
const lists = {}
|
||||
this.$sortedCategories().forEach((x) => {
|
||||
if (x.project_type === this.project.actualProjectType) {
|
||||
const header = x.header
|
||||
if (!lists[header]) {
|
||||
lists[header] = []
|
||||
}
|
||||
lists[header].push(x)
|
||||
}
|
||||
})
|
||||
return lists
|
||||
},
|
||||
patchData() {
|
||||
const data = {}
|
||||
// Promote selected categories to featured if there are less than 3 featured
|
||||
const newFeaturedTags = this.featuredTags.slice()
|
||||
if (newFeaturedTags.length < 1 && this.selectedTags.length > newFeaturedTags.length) {
|
||||
const nonFeaturedCategories = this.selectedTags.filter((x) => !newFeaturedTags.includes(x))
|
||||
|
||||
nonFeaturedCategories
|
||||
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
|
||||
.forEach((x) => newFeaturedTags.push(x))
|
||||
}
|
||||
// Convert selected and featured categories to backend-usable arrays
|
||||
const categories = newFeaturedTags.map((x) => x.name)
|
||||
const additionalCategories = this.selectedTags
|
||||
.filter((x) => !newFeaturedTags.includes(x))
|
||||
.map((x) => x.name)
|
||||
|
||||
if (
|
||||
categories.length !== this.project.categories.length ||
|
||||
categories.some((value) => !this.project.categories.includes(value))
|
||||
) {
|
||||
data.categories = categories
|
||||
}
|
||||
|
||||
if (
|
||||
additionalCategories.length !== this.project.additional_categories.length ||
|
||||
additionalCategories.some((value) => !this.project.additional_categories.includes(value))
|
||||
) {
|
||||
data.additional_categories = additionalCategories
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
hasChanges() {
|
||||
return Object.keys(this.patchData).length > 0
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleCategory(category) {
|
||||
if (this.selectedTags.includes(category)) {
|
||||
this.selectedTags = this.selectedTags.filter((x) => x !== category)
|
||||
if (this.featuredTags.includes(category)) {
|
||||
this.featuredTags = this.featuredTags.filter((x) => x !== category)
|
||||
}
|
||||
} else {
|
||||
this.selectedTags.push(category)
|
||||
}
|
||||
},
|
||||
toggleFeaturedCategory(category) {
|
||||
if (this.featuredTags.includes(category)) {
|
||||
this.featuredTags = this.featuredTags.filter((x) => x !== category)
|
||||
} else {
|
||||
this.featuredTags.push(category)
|
||||
}
|
||||
},
|
||||
saveChanges() {
|
||||
if (this.hasChanges) {
|
||||
this.patchProject(this.patchData)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.label__title {
|
||||
margin-top: var(--spacing-card-bg);
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.category-list {
|
||||
column-count: 4;
|
||||
column-gap: var(--spacing-card-lg);
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
|
||||
:deep(.category-selector) {
|
||||
margin-bottom: 0.5rem;
|
||||
.category-selector__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.icon {
|
||||
height: 1rem;
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
span {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1250px) {
|
||||
column-count: 3;
|
||||
}
|
||||
@media only screen and (max-width: 1024px) {
|
||||
column-count: 4;
|
||||
}
|
||||
@media only screen and (max-width: 960px) {
|
||||
column-count: 3;
|
||||
}
|
||||
@media only screen and (max-width: 750px) {
|
||||
column-count: 2;
|
||||
}
|
||||
@media only screen and (max-width: 530px) {
|
||||
column-count: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1565
apps/frontend/src/pages/[type]/[id]/version/[version].vue
Normal file
1565
apps/frontend/src/pages/[type]/[id]/version/[version].vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
340
apps/frontend/src/pages/[type]/[id]/versions.vue
Normal file
340
apps/frontend/src/pages/[type]/[id]/versions.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<div v-if="currentMember" class="card header-buttons">
|
||||
<FileInput
|
||||
:max-size="524288000"
|
||||
:accept="acceptFileFromProjectType(project.project_type)"
|
||||
prompt="Upload a version"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!isPermission(currentMember?.permissions, 1 << 0)"
|
||||
@change="handleFiles"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<span class="indicator">
|
||||
<InfoIcon /> Click to choose a file or drag one onto this page
|
||||
</span>
|
||||
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
|
||||
</div>
|
||||
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(filteredVersions.length / 20)"
|
||||
class="pagination-before"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
<div v-if="filteredVersions.length > 0" id="all-versions" class="universal-card all-versions">
|
||||
<div class="header">
|
||||
<div />
|
||||
<div>Version</div>
|
||||
<div>Supports</div>
|
||||
<div>Stats</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
|
||||
:key="version.id"
|
||||
class="version-button button-transparent"
|
||||
@click="
|
||||
$router.push(
|
||||
`/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`
|
||||
)
|
||||
"
|
||||
>
|
||||
<a
|
||||
v-tooltip="
|
||||
version.primaryFile.filename + ' (' + $formatBytes(version.primaryFile.size) + ')'
|
||||
"
|
||||
:href="version.primaryFile.url"
|
||||
class="download-button square-button brand-button"
|
||||
:class="version.version_type"
|
||||
:aria-label="`Download ${version.name}`"
|
||||
@click.stop="(event) => event.stopPropagation()"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
<nuxt-link
|
||||
:to="`/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`"
|
||||
class="version__title"
|
||||
>
|
||||
{{ version.name }}
|
||||
</nuxt-link>
|
||||
<div class="version__metadata">
|
||||
<VersionBadge v-if="version.version_type === 'release'" type="release" color="green" />
|
||||
<VersionBadge v-else-if="version.version_type === 'beta'" type="beta" color="orange" />
|
||||
<VersionBadge v-else-if="version.version_type === 'alpha'" type="alpha" color="red" />
|
||||
<span class="divider" />
|
||||
<span class="version_number">{{ version.version_number }}</span>
|
||||
</div>
|
||||
<div class="version__supports">
|
||||
<span>
|
||||
{{ version.loaders.map((x) => $formatCategory(x)).join(', ') }}
|
||||
</span>
|
||||
<span>{{ $formatVersion(version.game_versions) }}</span>
|
||||
</div>
|
||||
<div class="version__stats">
|
||||
<span>
|
||||
<strong>{{ $formatNumber(version.downloads) }}</strong>
|
||||
download<span v-if="version.downloads !== 1">s</span>
|
||||
</span>
|
||||
<span>
|
||||
Published on
|
||||
<strong>{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(filteredVersions.length / 20)"
|
||||
class="pagination-before"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
|
||||
import DownloadIcon from '~/assets/images/utils/download.svg?component'
|
||||
import UploadIcon from '~/assets/images/utils/upload.svg?component'
|
||||
import InfoIcon from '~/assets/images/utils/info.svg?component'
|
||||
import VersionBadge from '~/components/ui/Badge.vue'
|
||||
import FileInput from '~/components/ui/FileInput.vue'
|
||||
import DropArea from '~/components/ui/DropArea.vue'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
import VersionFilterControl from '~/components/ui/VersionFilterControl.vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
members: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
|
||||
const title = `${props.project.title} - Versions`
|
||||
const description = `Download and browse ${props.versions.length} ${
|
||||
props.project.title
|
||||
} versions. ${data.$formatNumber(props.project.downloads)} total downloads. Last updated ${data
|
||||
.$dayjs(props.project.updated)
|
||||
.format('MMM D, YYYY')}.`
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
})
|
||||
|
||||
const router = useNativeRouter()
|
||||
const route = useNativeRoute()
|
||||
|
||||
const currentPage = ref(Number(route.query.p ?? 1))
|
||||
const filteredVersions = computed(() => {
|
||||
const selectedGameVersions = getArrayOrString(route.query.g) ?? []
|
||||
const selectedLoaders = getArrayOrString(route.query.l) ?? []
|
||||
const selectedVersionTypes = getArrayOrString(route.query.c) ?? []
|
||||
|
||||
return props.versions.filter(
|
||||
(projectVersion) =>
|
||||
(selectedGameVersions.length === 0 ||
|
||||
selectedGameVersions.some((gameVersion) =>
|
||||
projectVersion.game_versions.includes(gameVersion)
|
||||
)) &&
|
||||
(selectedLoaders.length === 0 ||
|
||||
selectedLoaders.some((loader) => projectVersion.loaders.includes(loader))) &&
|
||||
(selectedVersionTypes.length === 0 ||
|
||||
selectedVersionTypes.includes(projectVersion.version_type))
|
||||
)
|
||||
})
|
||||
|
||||
function switchPage(page) {
|
||||
currentPage.value = page
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
p: currentPage.value !== 1 ? currentPage.value : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
await router.push({
|
||||
name: 'type-id-version-version',
|
||||
params: {
|
||||
type: props.project.project_type,
|
||||
id: props.project.slug ? props.project.slug : props.project.id,
|
||||
version: 'create',
|
||||
},
|
||||
state: {
|
||||
newPrimaryFile: files[0],
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.indicator {
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
align-items: center;
|
||||
color: var(--color-text-inactive);
|
||||
}
|
||||
}
|
||||
|
||||
.all-versions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template: 'download title supports stats';
|
||||
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
|
||||
color: var(--color-text-dark);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: bold;
|
||||
justify-content: left;
|
||||
margin-inline: var(--spacing-card-md);
|
||||
margin-bottom: var(--spacing-card-sm);
|
||||
column-gap: var(--spacing-card-sm);
|
||||
|
||||
div:first-child {
|
||||
grid-area: download;
|
||||
}
|
||||
|
||||
div:nth-child(2) {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
div:nth-child(3) {
|
||||
grid-area: supports;
|
||||
}
|
||||
|
||||
div:nth-child(4) {
|
||||
grid-area: stats;
|
||||
}
|
||||
}
|
||||
|
||||
.version-button {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'download title supports stats'
|
||||
'download metadata supports stats'
|
||||
'download dummy supports stats';
|
||||
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
|
||||
column-gap: var(--spacing-card-sm);
|
||||
justify-content: left;
|
||||
padding: var(--spacing-card-md);
|
||||
|
||||
.download-button {
|
||||
grid-area: download;
|
||||
}
|
||||
.version__title {
|
||||
grid-area: title;
|
||||
font-weight: bold;
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
.version__metadata {
|
||||
grid-area: metadata;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-card-xs);
|
||||
margin-top: var(--spacing-card-xs);
|
||||
}
|
||||
.version__supports {
|
||||
grid-area: supports;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-xs);
|
||||
}
|
||||
.version__stats {
|
||||
grid-area: stats;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.all-versions {
|
||||
.header {
|
||||
grid-template: 'download title';
|
||||
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
|
||||
|
||||
div:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.version-button {
|
||||
grid-template: 'download title' 'download metadata' 'download supports' 'download stats';
|
||||
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
|
||||
row-gap: var(--spacing-card-xs);
|
||||
|
||||
.version__supports {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-card-xs);
|
||||
}
|
||||
.version__metadata {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-card-md);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
.multiselect {
|
||||
flex: 1;
|
||||
}
|
||||
.checkbox-outer {
|
||||
min-width: fit-content;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2259
apps/frontend/src/pages/app.vue
Normal file
2259
apps/frontend/src/pages/app.vue
Normal file
File diff suppressed because one or more lines are too long
95
apps/frontend/src/pages/auth.vue
Normal file
95
apps/frontend/src/pages/auth.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<NuxtPage class="auth-container universal-card" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.auth-container {
|
||||
width: 26rem;
|
||||
max-width: calc(100% - 2rem);
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.auth-container h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 0 -1rem 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.auth-container p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-container .btn {
|
||||
font-weight: 700;
|
||||
min-height: 2.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.centered-btn {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.btn.continue-btn svg {
|
||||
margin: 0 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.third-party {
|
||||
display: grid;
|
||||
gap: var(--gap-md);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.third-party .btn {
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.third-party .btn svg {
|
||||
margin-right: var(--gap-sm);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 25.5rem) {
|
||||
.third-party .btn {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
.turnstile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid var(--color-button-bg);
|
||||
height: 66px;
|
||||
|
||||
iframe {
|
||||
margin: -1px;
|
||||
min-width: calc(100% + 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.auth-form .auth-form__input {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.auth-form__additional-options {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--gap-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
363
apps/frontend/src/pages/auth/authorize.vue
Normal file
363
apps/frontend/src/pages/auth/authorize.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="error" class="oauth-items">
|
||||
<div>
|
||||
<h1>{{ formatMessage(commonMessages.errorLabel) }}</h1>
|
||||
</div>
|
||||
<p>
|
||||
<span>{{ error.data.error }}: </span>
|
||||
{{ error.data.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="oauth-items">
|
||||
<div class="connected-items">
|
||||
<div class="profile-pics">
|
||||
<Avatar size="md" :src="app.icon_url" />
|
||||
<!-- <img class="profile-pic" :src="app.icon_url" alt="User profile picture" /> -->
|
||||
<div class="connection-indicator">→</div>
|
||||
<Avatar size="md" circle :src="auth.user.avatar_url" />
|
||||
<!-- <img class="profile-pic" :src="auth.user.avatar_url" alt="User profile picture" /> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="title">
|
||||
<h1>{{ formatMessage(messages.title, { appName: app.name }) }}</h1>
|
||||
</div>
|
||||
<div class="auth-info">
|
||||
<div class="scope-heading">
|
||||
<IntlFormatted
|
||||
:message-id="messages.appInfo"
|
||||
:values="{
|
||||
appName: app.name,
|
||||
creator: createdBy.username,
|
||||
}"
|
||||
>
|
||||
<template #strong="{ children }">
|
||||
<strong>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</strong>
|
||||
</template>
|
||||
<template #creator-link="{ children }">
|
||||
<nuxt-link class="text-link" :to="'/user/' + createdBy.id">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div class="scope-items">
|
||||
<div v-for="scopeItem in scopeDefinitions" :key="scopeItem">
|
||||
<div class="scope-item">
|
||||
<div class="scope-icon">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
{{ scopeItem }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button class="wide-button" large :action="onReject" :disabled="pending">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.decline) }}
|
||||
</Button>
|
||||
<Button class="wide-button" color="primary" large :action="onAuthorize" :disabled="pending">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.authorize) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="redirection-notice">
|
||||
<p class="redirect-instructions">
|
||||
<IntlFormatted :message-id="messages.redirectUrl" :values="{ url: redirectUri }">
|
||||
<template #redirect-url="{ children }">
|
||||
<span class="redirect-url">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Avatar } from '@modrinth/ui'
|
||||
import { XIcon, CheckIcon } from '@modrinth/assets'
|
||||
import { useBaseFetch } from '@/composables/fetch.js'
|
||||
import { useAuth } from '@/composables/auth.js'
|
||||
|
||||
import { useScopes } from '@/composables/auth/scopes.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
appInfo: {
|
||||
id: 'auth.authorize.app-info',
|
||||
defaultMessage:
|
||||
'<strong>{appName}</strong> by <creator-link>{creator}</creator-link> will be able to:',
|
||||
},
|
||||
authorize: {
|
||||
id: 'auth.authorize.action.authorize',
|
||||
defaultMessage: 'Authorize',
|
||||
},
|
||||
decline: {
|
||||
id: 'auth.authorize.action.decline',
|
||||
defaultMessage: 'Decline',
|
||||
},
|
||||
noRedirectUrlError: {
|
||||
id: 'auth.authorize.error.no-redirect-url',
|
||||
defaultMessage: 'No redirect location found in response',
|
||||
},
|
||||
redirectUrl: {
|
||||
id: 'auth.authorize.redirect-url',
|
||||
defaultMessage: 'You will be redirected to <redirect-url>{url}</redirect-url>',
|
||||
},
|
||||
title: {
|
||||
id: 'auth.authorize.authorize-app-name',
|
||||
defaultMessage: 'Authorize {appName}',
|
||||
},
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
|
||||
const router = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
const { scopesToDefinitions } = useScopes()
|
||||
|
||||
const clientId = router.query?.client_id || false
|
||||
const redirectUri = router.query?.redirect_uri || false
|
||||
const scope = router.query?.scope || false
|
||||
const state = router.query?.state || false
|
||||
|
||||
const getFlowIdAuthorization = async () => {
|
||||
const query = {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
}
|
||||
if (state) {
|
||||
query.state = state
|
||||
}
|
||||
|
||||
const authorization = await useBaseFetch('oauth/authorize', {
|
||||
method: 'GET',
|
||||
internal: true,
|
||||
query,
|
||||
}) // This will contain the flow_id and oauth_client_id for accepting the oauth on behalf of the user
|
||||
|
||||
if (typeof authorization === 'string') {
|
||||
await navigateTo(authorization, {
|
||||
external: true,
|
||||
})
|
||||
}
|
||||
|
||||
return authorization
|
||||
}
|
||||
|
||||
const {
|
||||
data: authorizationData,
|
||||
pending,
|
||||
error,
|
||||
} = await useAsyncData('authorization', getFlowIdAuthorization)
|
||||
|
||||
const { data: app } = await useAsyncData('oauth/app/' + clientId, () =>
|
||||
useBaseFetch('oauth/app/' + clientId, {
|
||||
method: 'GET',
|
||||
internal: true,
|
||||
})
|
||||
)
|
||||
|
||||
const scopeDefinitions = scopesToDefinitions(BigInt(authorizationData.value?.requested_scopes || 0))
|
||||
|
||||
const { data: createdBy } = await useAsyncData('user/' + app.value.created_by, () =>
|
||||
useBaseFetch('user/' + app.value.created_by, {
|
||||
method: 'GET',
|
||||
apiVersion: 3,
|
||||
})
|
||||
)
|
||||
|
||||
const onAuthorize = async () => {
|
||||
try {
|
||||
const res = await useBaseFetch('oauth/accept', {
|
||||
method: 'POST',
|
||||
internal: true,
|
||||
body: {
|
||||
flow: authorizationData.value.flow_id,
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof res === 'string') {
|
||||
navigateTo(res, {
|
||||
external: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(formatMessage(messages.noRedirectUrlError))
|
||||
} catch (error) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onReject = async () => {
|
||||
try {
|
||||
const res = await useBaseFetch('oauth/reject', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
flow: authorizationData.value.flow_id,
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof res === 'string') {
|
||||
navigateTo(res, {
|
||||
external: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(formatMessage(messages.noRedirectUrlError))
|
||||
} catch (error) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oauth-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xl);
|
||||
}
|
||||
|
||||
.scope-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-icon {
|
||||
display: flex;
|
||||
|
||||
color: var(--color-raised-bg);
|
||||
background-color: var(--color-green);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
padding: var(--gap-xs);
|
||||
}
|
||||
.title {
|
||||
margin-inline: auto;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
.redirection-notice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
text-align: center;
|
||||
|
||||
.redirect-instructions {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.redirect-url {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.wide-button {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
justify-content: center;
|
||||
}
|
||||
.auth-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-heading {
|
||||
margin-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.profile-pics {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
|
||||
.connection-indicator {
|
||||
// Make sure the text sits in the middle and is centered.
|
||||
// Make the text large, and make sure it's not selectable.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
user-select: none;
|
||||
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
border-radius: 50%;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.dotted-border-line {
|
||||
width: 75%;
|
||||
border: 0.1rem dashed var(--color-divider);
|
||||
}
|
||||
|
||||
.connected-items {
|
||||
// Display dotted-border-line under profile-pics and centered behind them
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 1rem;
|
||||
|
||||
// Display profile-pics on top of dotted-border-line
|
||||
.profile-pics {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Display dotted-border-line behind profile-pics
|
||||
.dotted-border-line {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
apps/frontend/src/pages/auth/reset-password.vue
Normal file
234
apps/frontend/src/pages/auth/reset-password.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{ formatMessage(messages.longTitle) }}</h1>
|
||||
<section class="auth-form">
|
||||
<template v-if="step === 'choose_method'">
|
||||
<p>
|
||||
{{ formatMessage(methodChoiceMessages.description) }}
|
||||
</p>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label for="email" hidden>
|
||||
{{ formatMessage(methodChoiceMessages.emailUsernameLabel) }}
|
||||
</label>
|
||||
<MailIcon />
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(methodChoiceMessages.emailUsernamePlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NuxtTurnstile
|
||||
ref="turnstile"
|
||||
v-model="token"
|
||||
class="turnstile"
|
||||
:options="{ theme: $colorMode.value === 'light' ? 'light' : 'dark' }"
|
||||
/>
|
||||
|
||||
<button class="btn btn-primary centered-btn" :disabled="!token" @click="recovery">
|
||||
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="step === 'passed_challenge'">
|
||||
<p>{{ formatMessage(postChallengeMessages.description) }}</p>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<KeyIcon />
|
||||
<input
|
||||
id="password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label for="confirm-password" hidden>
|
||||
{{ formatMessage(commonMessages.passwordLabel) }}
|
||||
</label>
|
||||
<KeyIcon />
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="confirmNewPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(postChallengeMessages.confirmPasswordLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="auth-form__input btn btn-primary continue-btn" @click="changePassword">
|
||||
{{ formatMessage(postChallengeMessages.action) }}
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { SendIcon, MailIcon, KeyIcon } from '@modrinth/assets'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const methodChoiceMessages = defineMessages({
|
||||
description: {
|
||||
id: 'auth.reset-password.method-choice.description',
|
||||
defaultMessage:
|
||||
"Enter your email below and we'll send a recovery link to allow you to recover your account.",
|
||||
},
|
||||
emailUsernameLabel: {
|
||||
id: 'auth.reset-password.method-choice.email-username.label',
|
||||
defaultMessage: 'Email or username',
|
||||
},
|
||||
emailUsernamePlaceholder: {
|
||||
id: 'auth.reset-password.method-choice.email-username.placeholder',
|
||||
defaultMessage: 'Email',
|
||||
},
|
||||
action: {
|
||||
id: 'auth.reset-password.method-choice.action',
|
||||
defaultMessage: 'Send recovery email',
|
||||
},
|
||||
})
|
||||
|
||||
const postChallengeMessages = defineMessages({
|
||||
description: {
|
||||
id: 'auth.reset-password.post-challenge.description',
|
||||
defaultMessage: 'Enter your new password below to gain access to your account.',
|
||||
},
|
||||
confirmPasswordLabel: {
|
||||
id: 'auth.reset-password.post-challenge.confirm-password.label',
|
||||
defaultMessage: 'Confirm password',
|
||||
},
|
||||
action: {
|
||||
id: 'auth.reset-password.post-challenge.action',
|
||||
defaultMessage: 'Reset password',
|
||||
},
|
||||
})
|
||||
|
||||
// NOTE(Brawaru): Vite uses esbuild for minification so can't combine these
|
||||
// because it'll keep the original prop names compared to consts, which names
|
||||
// will be mangled.
|
||||
const emailSentNotificationMessages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.reset-password.notification.email-sent.title',
|
||||
defaultMessage: 'Email sent',
|
||||
},
|
||||
text: {
|
||||
id: 'auth.reset-password.notification.email-sent.text',
|
||||
defaultMessage:
|
||||
'An email with instructions has been sent to you if the email was previously saved on your account.',
|
||||
},
|
||||
})
|
||||
|
||||
const passwordResetNotificationMessages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.reset-password.notification.password-reset.title',
|
||||
defaultMessage: 'Password successfully reset',
|
||||
},
|
||||
text: {
|
||||
id: 'auth.reset-password.notification.password-reset.text',
|
||||
defaultMessage: 'You can now log-in into your account with your new password.',
|
||||
},
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.reset-password.title',
|
||||
defaultMessage: 'Reset Password',
|
||||
},
|
||||
longTitle: {
|
||||
id: 'auth.reset-password.title.long',
|
||||
defaultMessage: 'Reset your password',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.title)} - Modrinth`,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
if (auth.value.user) {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
|
||||
const route = useNativeRoute()
|
||||
|
||||
const step = ref('choose_method')
|
||||
|
||||
if (route.query.flow) {
|
||||
step.value = 'passed_challenge'
|
||||
}
|
||||
|
||||
const turnstile = ref()
|
||||
|
||||
const email = ref('')
|
||||
const token = ref('')
|
||||
|
||||
async function recovery() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch('auth/password/reset', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: email.value,
|
||||
challenge: token.value,
|
||||
},
|
||||
})
|
||||
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(emailSentNotificationMessages.title),
|
||||
text: formatMessage(emailSentNotificationMessages.text),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
turnstile.value?.reset()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const newPassword = ref('')
|
||||
const confirmNewPassword = ref('')
|
||||
|
||||
async function changePassword() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch('auth/password', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
new_password: newPassword.value,
|
||||
flow: route.query.flow,
|
||||
},
|
||||
})
|
||||
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(passwordResetNotificationMessages.title),
|
||||
text: formatMessage(passwordResetNotificationMessages.text),
|
||||
type: 'success',
|
||||
})
|
||||
await navigateTo('/auth/sign-in')
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
turnstile.value?.reset()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
279
apps/frontend/src/pages/auth/sign-in.vue
Normal file
279
apps/frontend/src/pages/auth/sign-in.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="flow">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCode"
|
||||
maxlength="11"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
|
||||
autocomplete="one-time-code"
|
||||
autofocus
|
||||
@keyup.enter="begin2FASignIn"
|
||||
/>
|
||||
|
||||
<button class="btn btn-primary continue-btn" @click="begin2FASignIn">
|
||||
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1>{{ formatMessage(messages.signInWithLabel) }}</h1>
|
||||
|
||||
<section class="third-party">
|
||||
<a class="btn" :href="getAuthUrl('discord', redirectTarget)">
|
||||
<SSODiscordIcon />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
|
||||
<SSOGitHubIcon />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
|
||||
<SSOMicrosoftIcon />
|
||||
<span>Microsoft</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
|
||||
<SSOGoogleIcon />
|
||||
<span>Google</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
|
||||
<SSOSteamIcon />
|
||||
<span>Steam</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
|
||||
<SSOGitLabIcon />
|
||||
<span>GitLab</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<h1>{{ formatMessage(messages.usePasswordLabel) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<div class="iconified-input">
|
||||
<label for="email" hidden>{{ formatMessage(messages.emailUsernameLabel) }}</label>
|
||||
<MailIcon />
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(messages.emailUsernameLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label for="password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
|
||||
<KeyIcon />
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(messages.passwordLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NuxtTurnstile
|
||||
ref="turnstile"
|
||||
v-model="token"
|
||||
class="turnstile"
|
||||
:options="{ theme: $colorMode.value === 'light' ? 'light' : 'dark' }"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="btn btn-primary continue-btn centered-btn"
|
||||
:disabled="!token"
|
||||
@click="beginPasswordSignIn()"
|
||||
>
|
||||
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
|
||||
<div class="auth-form__additional-options">
|
||||
<IntlFormatted :message-id="messages.additionalOptionsLabel">
|
||||
<template #forgot-password-link="{ children }">
|
||||
<NuxtLink class="text-link" to="/auth/reset-password">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #create-account-link="{ children }">
|
||||
<NuxtLink class="text-link" :to="signUpLink">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
RightArrowIcon,
|
||||
SSOGitHubIcon,
|
||||
SSOMicrosoftIcon,
|
||||
SSOSteamIcon,
|
||||
SSOGoogleIcon,
|
||||
SSODiscordIcon,
|
||||
SSOGitLabIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
} from '@modrinth/assets'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
additionalOptionsLabel: {
|
||||
id: 'auth.sign-in.additional-options',
|
||||
defaultMessage:
|
||||
'<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>',
|
||||
},
|
||||
emailUsernameLabel: {
|
||||
id: 'auth.sign-in.email-username.label',
|
||||
defaultMessage: 'Email or username',
|
||||
},
|
||||
passwordLabel: {
|
||||
id: 'auth.sign-in.password.label',
|
||||
defaultMessage: 'Password',
|
||||
},
|
||||
signInWithLabel: {
|
||||
id: 'auth.sign-in.sign-in-with',
|
||||
defaultMessage: 'Sign in with',
|
||||
},
|
||||
signInTitle: {
|
||||
id: 'auth.sign-in.title',
|
||||
defaultMessage: 'Sign In',
|
||||
},
|
||||
twoFactorCodeInputPlaceholder: {
|
||||
id: 'auth.sign-in.2fa.placeholder',
|
||||
defaultMessage: 'Enter code...',
|
||||
},
|
||||
twoFactorCodeLabel: {
|
||||
id: 'auth.sign-in.2fa.label',
|
||||
defaultMessage: 'Enter two-factor code',
|
||||
},
|
||||
twoFactorCodeLabelDescription: {
|
||||
id: 'auth.sign-in.2fa.description',
|
||||
defaultMessage: 'Please enter a two-factor code to proceed.',
|
||||
},
|
||||
usePasswordLabel: {
|
||||
id: 'auth.sign-in.use-password',
|
||||
defaultMessage: 'Or use a password',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title() {
|
||||
return `${formatMessage(messages.signInTitle)} - Modrinth`
|
||||
},
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const route = useNativeRoute()
|
||||
|
||||
const redirectTarget = route.query.redirect || ''
|
||||
|
||||
if (route.fullPath.includes('new_account=true')) {
|
||||
await navigateTo(
|
||||
`/auth/welcome?authToken=${route.query.code}${
|
||||
route.query.redirect ? `&redirect=${encodeURIComponent(route.query.redirect)}` : ''
|
||||
}`
|
||||
)
|
||||
} else if (route.query.code) {
|
||||
await finishSignIn()
|
||||
}
|
||||
|
||||
if (auth.value.user) {
|
||||
await finishSignIn()
|
||||
}
|
||||
|
||||
const turnstile = ref()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const token = ref('')
|
||||
|
||||
const flow = ref(route.query.flow)
|
||||
|
||||
const signUpLink = computed(
|
||||
() => `/auth/sign-up${route.query.redirect ? `?redirect=${route.query.redirect}` : ''}`
|
||||
)
|
||||
|
||||
async function beginPasswordSignIn() {
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: email.value,
|
||||
password: password.value,
|
||||
challenge: token.value,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.flow) {
|
||||
flow.value = res.flow
|
||||
} else {
|
||||
await finishSignIn(res.session)
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
turnstile.value?.reset()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const twoFactorCode = ref(null)
|
||||
async function begin2FASignIn() {
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/login/2fa', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
flow: flow.value,
|
||||
code: twoFactorCode.value ? twoFactorCode.value.toString() : twoFactorCode.value,
|
||||
},
|
||||
})
|
||||
|
||||
await finishSignIn(res.session)
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
turnstile.value?.reset()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function finishSignIn(token) {
|
||||
if (token) {
|
||||
await useAuth(token)
|
||||
await useUser()
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
const redirect = decodeURIComponent(route.query.redirect)
|
||||
await navigateTo(redirect, {
|
||||
replace: true,
|
||||
})
|
||||
} else {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
279
apps/frontend/src/pages/auth/sign-up.vue
Normal file
279
apps/frontend/src/pages/auth/sign-up.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{ formatMessage(messages.signUpWithTitle) }}</h1>
|
||||
|
||||
<section class="third-party">
|
||||
<a class="btn discord-btn" :href="getAuthUrl('discord', redirectTarget)">
|
||||
<SSODiscordIcon />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
|
||||
<SSOGitHubIcon />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
|
||||
<SSOMicrosoftIcon />
|
||||
<span>Microsoft</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
|
||||
<SSOGoogleIcon />
|
||||
<span>Google</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
|
||||
<SSOSteamIcon />
|
||||
<span>Steam</span>
|
||||
</a>
|
||||
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
|
||||
<SSOGitLabIcon />
|
||||
<span>GitLab</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<h1>{{ formatMessage(messages.createAccountTitle) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<div class="iconified-input">
|
||||
<label for="email" hidden>{{ formatMessage(messages.emailLabel) }}</label>
|
||||
<MailIcon />
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(messages.emailLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label for="username" hidden>{{ formatMessage(messages.usernameLabel) }}</label>
|
||||
<UserIcon />
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(messages.usernameLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label for="password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
|
||||
<KeyIcon />
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
class="auth-form__input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(messages.passwordLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label for="confirm-password" hidden>{{ formatMessage(messages.passwordLabel) }}</label>
|
||||
<KeyIcon />
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="auth-form__input"
|
||||
:placeholder="formatMessage(messages.confirmPasswordLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
v-model="subscribe"
|
||||
class="subscribe-btn"
|
||||
:label="formatMessage(messages.subscribeLabel)"
|
||||
:description="formatMessage(messages.subscribeLabel)"
|
||||
/>
|
||||
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.legalDisclaimer">
|
||||
<template #terms-link="{ children }">
|
||||
<NuxtLink to="/legal/terms" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #privacy-policy-link="{ children }">
|
||||
<NuxtLink to="/legal/privacy" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<NuxtTurnstile
|
||||
ref="turnstile"
|
||||
v-model="token"
|
||||
class="turnstile"
|
||||
:options="{ theme: $colorMode.value === 'light' ? 'light' : 'dark' }"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="btn btn-primary continue-btn centered-btn"
|
||||
:disabled="!token"
|
||||
@click="createAccount"
|
||||
>
|
||||
{{ formatMessage(messages.createAccountButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
|
||||
<div class="auth-form__additional-options">
|
||||
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
|
||||
<NuxtLink class="text-link" :to="signInLink">
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
RightArrowIcon,
|
||||
UserIcon,
|
||||
SSOGitHubIcon,
|
||||
SSOMicrosoftIcon,
|
||||
SSOGoogleIcon,
|
||||
SSOSteamIcon,
|
||||
SSODiscordIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
SSOGitLabIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Checkbox } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.sign-up.title',
|
||||
defaultMessage: 'Sign Up',
|
||||
},
|
||||
signUpWithTitle: {
|
||||
id: 'auth.sign-up.title.sign-up-with',
|
||||
defaultMessage: 'Sign up with',
|
||||
},
|
||||
createAccountTitle: {
|
||||
id: 'auth.sign-up.title.create-account',
|
||||
defaultMessage: 'Or create an account yourself',
|
||||
},
|
||||
emailLabel: {
|
||||
id: 'auth.sign-up.email.label',
|
||||
defaultMessage: 'Email',
|
||||
},
|
||||
usernameLabel: {
|
||||
id: 'auth.sign-up.label.username',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
passwordLabel: {
|
||||
id: 'auth.sign-up.password.label',
|
||||
defaultMessage: 'Password',
|
||||
},
|
||||
confirmPasswordLabel: {
|
||||
id: 'auth.sign-up.confirm-password.label',
|
||||
defaultMessage: 'Confirm password',
|
||||
},
|
||||
subscribeLabel: {
|
||||
id: 'auth.sign-up.subscribe.label',
|
||||
defaultMessage: 'Subscribe to updates about Modrinth',
|
||||
},
|
||||
legalDisclaimer: {
|
||||
id: 'auth.sign-up.legal-dislaimer',
|
||||
defaultMessage:
|
||||
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
|
||||
},
|
||||
createAccountButton: {
|
||||
id: 'auth.sign-up.action.create-account',
|
||||
defaultMessage: 'Create account',
|
||||
},
|
||||
alreadyHaveAccountLabel: {
|
||||
id: 'auth.sign-up.sign-in-option.title',
|
||||
defaultMessage: 'Already have an account?',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.title)} - Modrinth`,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const route = useNativeRoute()
|
||||
|
||||
const redirectTarget = route.query.redirect
|
||||
|
||||
if (route.fullPath.includes('new_account=true')) {
|
||||
await navigateTo(
|
||||
`/auth/welcome?authToken=${route.query.code}${
|
||||
route.query.redirect ? `&redirect=${encodeURIComponent(route.query.redirect)}` : ''
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
if (auth.value.user) {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
|
||||
const turnstile = ref()
|
||||
|
||||
const email = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const token = ref('')
|
||||
const subscribe = ref(true)
|
||||
|
||||
const signInLink = computed(
|
||||
() => `/auth/sign-in${route.query.redirect ? `?redirect=${route.query.redirect}` : ''}`
|
||||
)
|
||||
|
||||
async function createAccount() {
|
||||
startLoading()
|
||||
try {
|
||||
if (confirmPassword.value !== password.value) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: formatMessage({
|
||||
id: 'auth.sign-up.notification.password-mismatch.text',
|
||||
defaultMessage: 'Passwords do not match!',
|
||||
}),
|
||||
type: 'error',
|
||||
})
|
||||
turnstile.value?.reset()
|
||||
}
|
||||
|
||||
const res = await useBaseFetch('auth/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
email: email.value,
|
||||
challenge: token.value,
|
||||
sign_up_newsletter: subscribe.value,
|
||||
},
|
||||
})
|
||||
|
||||
await useAuth(res.session)
|
||||
await useUser()
|
||||
|
||||
if (route.query.redirect) {
|
||||
await navigateTo(route.query.redirect)
|
||||
} else {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
turnstile.value?.reset()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
152
apps/frontend/src/pages/auth/verify-email.vue
Normal file
152
apps/frontend/src/pages/auth/verify-email.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="auth.user && auth.user.email_verified && !success">
|
||||
<h1>{{ formatMessage(alreadyVerifiedMessages.title) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<p>{{ formatMessage(alreadyVerifiedMessages.description) }}</p>
|
||||
|
||||
<NuxtLink class="btn" to="/settings/account">
|
||||
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
|
||||
</NuxtLink>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="success">
|
||||
<h1>{{ formatMessage(postVerificationMessages.title) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
|
||||
|
||||
<NuxtLink v-if="auth.user" class="btn" link="/settings/account">
|
||||
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
|
||||
</NuxtLink>
|
||||
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
|
||||
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1>{{ formatMessage(failedVerificationMessages.title) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<p>
|
||||
<template v-if="auth.user">
|
||||
{{ formatMessage(failedVerificationMessages.loggedInDescription) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(failedVerificationMessages.description) }}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<button v-if="auth.user" class="btn btn-primary continue-btn" @click="resendVerifyEmail">
|
||||
{{ formatMessage(failedVerificationMessages.action) }} <RightArrowIcon />
|
||||
</button>
|
||||
|
||||
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
|
||||
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { SettingsIcon, RightArrowIcon } from '@modrinth/assets'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.verify-email.title',
|
||||
defaultMessage: 'Verify Email',
|
||||
},
|
||||
accountSettings: {
|
||||
id: 'auth.verify-email.action.account-settings',
|
||||
defaultMessage: 'Account settings',
|
||||
},
|
||||
signIn: {
|
||||
id: 'auth.verify-email.action.sign-in',
|
||||
defaultMessage: 'Sign in',
|
||||
},
|
||||
})
|
||||
|
||||
const alreadyVerifiedMessages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.verify-email.already-verified.title',
|
||||
defaultMessage: 'Email already verified',
|
||||
},
|
||||
description: {
|
||||
id: 'auth.verify-email.already-verified.description',
|
||||
defaultMessage: 'Your email is already verified!',
|
||||
},
|
||||
})
|
||||
|
||||
const postVerificationMessages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.verify-email.post-verification.title',
|
||||
defaultMessage: 'Email verification',
|
||||
},
|
||||
description: {
|
||||
id: 'auth.verify-email.post-verification.description',
|
||||
defaultMessage: 'Your email address has been successfully verified!',
|
||||
},
|
||||
})
|
||||
|
||||
const failedVerificationMessages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.verify-email.failed-verification.title',
|
||||
defaultMessage: 'Email verification failed',
|
||||
},
|
||||
description: {
|
||||
id: 'auth.verify-email.failed-verification.description',
|
||||
defaultMessage:
|
||||
'We were unable to verify your email. Try re-sending the verification email through your dashboard by signing in.',
|
||||
},
|
||||
loggedInDescription: {
|
||||
id: 'auth.verify-email.failed-verification.description.logged-in',
|
||||
defaultMessage:
|
||||
'We were unable to verify your email. Try re-sending the verification email through the button below.',
|
||||
},
|
||||
action: {
|
||||
id: 'auth.verify-email.failed-verification.action',
|
||||
defaultMessage: 'Resend verification email',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.title)} - Modrinth`,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const success = ref(false)
|
||||
const route = useNativeRoute()
|
||||
|
||||
if (route.query.flow) {
|
||||
try {
|
||||
const emailVerified = useState('emailVerified', () => null)
|
||||
|
||||
if (emailVerified.value === null) {
|
||||
await useBaseFetch('auth/email/verify', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
flow: route.query.flow,
|
||||
},
|
||||
})
|
||||
emailVerified.value = true
|
||||
success.value = true
|
||||
}
|
||||
|
||||
if (emailVerified.value) {
|
||||
success.value = true
|
||||
|
||||
if (auth.value.token) {
|
||||
await useAuth(auth.value.token)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
success.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
95
apps/frontend/src/pages/auth/welcome.vue
Normal file
95
apps/frontend/src/pages/auth/welcome.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{ formatMessage(messages.welcomeLongTitle) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<p>
|
||||
{{ formatMessage(messages.welcomeDescription) }}
|
||||
</p>
|
||||
|
||||
<Checkbox
|
||||
v-model="subscribe"
|
||||
class="subscribe-btn"
|
||||
:label="formatMessage(messages.subscribeCheckbox)"
|
||||
:description="formatMessage(messages.subscribeCheckbox)"
|
||||
/>
|
||||
|
||||
<button class="btn btn-primary continue-btn centered-btn" @click="continueSignUp">
|
||||
{{ formatMessage(commonMessages.continueButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.tosLabel">
|
||||
<template #terms-link="{ children }">
|
||||
<NuxtLink to="/legal/terms" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #privacy-policy-link="{ children }">
|
||||
<NuxtLink to="/legal/privacy" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Checkbox } from '@modrinth/ui'
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
subscribeCheckbox: {
|
||||
id: 'auth.welcome.checkbox.subscribe',
|
||||
defaultMessage: 'Subscribe to updates about Modrinth',
|
||||
},
|
||||
tosLabel: {
|
||||
id: 'auth.welcome.label.tos',
|
||||
defaultMessage:
|
||||
"By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
|
||||
},
|
||||
welcomeDescription: {
|
||||
id: 'auth.welcome.description',
|
||||
defaultMessage:
|
||||
'Thank you for creating an account. You can now follow and create projects, receive updates about your favorite projects, and more!',
|
||||
},
|
||||
welcomeLongTitle: {
|
||||
id: 'auth.welcome.long-title',
|
||||
defaultMessage: 'Welcome to Modrinth!',
|
||||
},
|
||||
welcomeTitle: {
|
||||
id: 'auth.welcome.title',
|
||||
defaultMessage: 'Welcome',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.welcomeTitle)} - Modrinth`,
|
||||
})
|
||||
|
||||
const subscribe = ref(true)
|
||||
|
||||
async function continueSignUp() {
|
||||
const route = useNativeRoute()
|
||||
|
||||
await useAuth(route.query.authToken)
|
||||
await useUser()
|
||||
|
||||
if (subscribe.value) {
|
||||
try {
|
||||
await useBaseFetch('auth/email/subscribe', {
|
||||
method: 'POST',
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
await navigateTo(route.query.redirect)
|
||||
} else {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
810
apps/frontend/src/pages/collection/[id].vue
Normal file
810
apps/frontend/src/pages/collection/[id].vue
Normal file
@@ -0,0 +1,810 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
v-if="auth.user && auth.user.id === creator.id"
|
||||
ref="deleteModal"
|
||||
:title="formatMessage(messages.deleteModalTitle)"
|
||||
:description="formatMessage(messages.deleteModalDescription)"
|
||||
:has-to-type="false"
|
||||
:proceed-label="formatMessage(commonMessages.deleteLabel)"
|
||||
@proceed="deleteCollection()"
|
||||
/>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<div class="card">
|
||||
<div class="card__overlay input-group">
|
||||
<template v-if="canEdit && isEditing === false">
|
||||
<Button @click="isEditing = true">
|
||||
<EditIcon />
|
||||
{{ formatMessage(commonMessages.editButton) }}
|
||||
</Button>
|
||||
<Button id="delete-collection" @click="() => $refs.deleteModal.show()">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="canEdit && isEditing === true">
|
||||
<PopoutMenu class="btn" position="bottom" direction="right">
|
||||
<EditIcon /> {{ formatMessage(messages.editIconButton) }}
|
||||
<template #menu>
|
||||
<span class="icon-edit-menu">
|
||||
<FileInput
|
||||
id="project-icon"
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="btn btn-transparent upload"
|
||||
style="white-space: nowrap"
|
||||
prompt=""
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon />
|
||||
{{ formatMessage(messages.uploadIconButton) }}
|
||||
</FileInput>
|
||||
<Button
|
||||
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
||||
style="white-space: nowrap"
|
||||
transparent
|
||||
@click="
|
||||
() => {
|
||||
deletedIcon = true
|
||||
previewImage = null
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteIconButton) }}
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Editing -->
|
||||
<template v-if="isEditing">
|
||||
<div class="inputs universal-labels">
|
||||
<div class="avatar-section">
|
||||
<Avatar
|
||||
size="md"
|
||||
:src="deletedIcon ? null : previewImage ? previewImage : collection.icon_url"
|
||||
/>
|
||||
</div>
|
||||
<label for="collection-title">
|
||||
<span class="label__title"> {{ formatMessage(commonMessages.titleLabel) }} </span>
|
||||
</label>
|
||||
<input id="collection-title" v-model="name" maxlength="255" type="text" />
|
||||
<label for="collection-description">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea id="collection-description" v-model="summary" maxlength="255" />
|
||||
</div>
|
||||
<label for="visibility">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(commonMessages.visibilityLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="visibility"
|
||||
v-model="visibility"
|
||||
:options="['listed', 'unlisted', 'private']"
|
||||
:disabled="visibility === 'rejected'"
|
||||
:multiple="false"
|
||||
:display-name="
|
||||
(s) => {
|
||||
if (s === 'listed') return formatMessage(commonMessages.publicLabel)
|
||||
return formatMessage(commonMessages[`${s}Label`])
|
||||
}
|
||||
"
|
||||
:searchable="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="push-right input-group">
|
||||
<Button @click="isEditing = false">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button color="primary" @click="saveChanges()">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Content -->
|
||||
<template v-if="!isEditing">
|
||||
<div class="page-header__icon">
|
||||
<Avatar size="md" :src="collection.icon_url" />
|
||||
</div>
|
||||
<div class="page-header__text">
|
||||
<h1 class="title">{{ collection.name }}</h1>
|
||||
|
||||
<div>
|
||||
<span class="collection-label">
|
||||
<BoxIcon /> {{ formatMessage(messages.collectionLabel) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="collection-info">
|
||||
<div class="metadata-item markdown-body collection-description">
|
||||
<p>{{ collection.description }}</p>
|
||||
</div>
|
||||
|
||||
<hr class="card-divider" />
|
||||
|
||||
<div v-if="canEdit" class="primary-stat">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<WorldIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> {{ formatMessage(commonMessages.publicLabel) }} </strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> {{ formatMessage(commonMessages.unlistedLabel) }} </strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> {{ formatMessage(commonMessages.privateLabel) }} </strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> {{ formatMessage(commonMessages.rejectedLabel) }} </strong>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="primary-stat">
|
||||
<LibraryIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div v-if="projects" class="primary-stat__text">
|
||||
<IntlFormatted
|
||||
:message-id="messages.projectsCountLabel"
|
||||
:values="{ count: formatCompactNumber(projects.length || 0) }"
|
||||
>
|
||||
<template #stat="{ children }">
|
||||
<span class="primary-stat__counter">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata-item">
|
||||
<div
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(collection.created),
|
||||
time: new Date(collection.created),
|
||||
})
|
||||
"
|
||||
class="date"
|
||||
>
|
||||
<CalendarIcon />
|
||||
<label>
|
||||
{{
|
||||
formatMessage(messages.createdAtLabel, {
|
||||
ago: formatRelativeTime(collection.created),
|
||||
})
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="collection.id !== 'following'" class="metadata-item">
|
||||
<div
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(collection.updated),
|
||||
time: new Date(collection.updated),
|
||||
})
|
||||
"
|
||||
class="date"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
<label>
|
||||
{{
|
||||
formatMessage(messages.updatedAtLabel, {
|
||||
ago: formatRelativeTime(collection.updated),
|
||||
})
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="card-divider" />
|
||||
|
||||
<div class="collection-info">
|
||||
<h2 class="card-header">{{ formatMessage(messages.curatedByLabel) }}</h2>
|
||||
<div class="metadata-item">
|
||||
<nuxt-link
|
||||
class="team-member columns button-transparent"
|
||||
:to="'/user/' + creator.username"
|
||||
>
|
||||
<Avatar :src="creator.avatar_url" :alt="creator.username" size="sm" circle />
|
||||
|
||||
<div class="member-info">
|
||||
<p class="name">{{ creator.username }}</p>
|
||||
<p class="role">{{ formatMessage(messages.ownerLabel) }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<!-- <hr class="card-divider" />
|
||||
<div class="input-group">
|
||||
<Button @click="() => $refs.shareModal.show()">
|
||||
<ShareIcon />
|
||||
Share
|
||||
</Button>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<Promotion :external="false" query-param="" />
|
||||
|
||||
<nav class="navigation-card">
|
||||
<NavRow
|
||||
:links="[
|
||||
{
|
||||
label: formatMessage(commonMessages.allProjectType),
|
||||
href: `/collection/${collection.id}`,
|
||||
},
|
||||
...projectTypes.map((x) => {
|
||||
return {
|
||||
label: formatMessage(getProjectTypeMessage(x, true)),
|
||||
href: `/collection/${collection.id}/${x}s`,
|
||||
}
|
||||
}),
|
||||
]"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="
|
||||
formatMessage(
|
||||
commonMessages[`${cosmetics.searchDisplayMode.collection || 'list'}InputView`]
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
formatMessage(
|
||||
commonMessages[`${cosmetics.searchDisplayMode.collection || 'list'}InputView`]
|
||||
)
|
||||
"
|
||||
class="square-button"
|
||||
@click="cycleSearchDisplayMode()"
|
||||
>
|
||||
<GridIcon v-if="cosmetics.searchDisplayMode.collection === 'grid'" />
|
||||
<ImageIcon v-else-if="cosmetics.searchDisplayMode.collection === 'gallery'" />
|
||||
<ListIcon v-else />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
v-if="projects && projects?.length > 0"
|
||||
:class="
|
||||
'project-list display-mode--' + (cosmetics.searchDisplayMode.collection || 'list')
|
||||
"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="project in (route.params.projectType !== undefined
|
||||
? projects.filter(
|
||||
(x) =>
|
||||
x.project_type ===
|
||||
route.params.projectType.substr(0, route.params.projectType.length - 1)
|
||||
)
|
||||
: projects
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
: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'"
|
||||
:follows="project.followers ? project.followers.toString() : '0'"
|
||||
:featured-image="project.gallery.find((element) => element.featured)?.url"
|
||||
:icon-url="project.icon_url"
|
||||
:name="project.title"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:color="project.color"
|
||||
:show-updated-date="!canEdit && collection.id !== 'following'"
|
||||
:show-created-date="!canEdit && collection.id !== 'following'"
|
||||
>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="iconified-button remove-btn"
|
||||
@click="
|
||||
() => {
|
||||
removeProjects = [project]
|
||||
saveChanges()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeProjectButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="collection.id === 'following'"
|
||||
class="iconified-button"
|
||||
@click="unfollowProject(project)"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.unfollowProjectButton) }}
|
||||
</button>
|
||||
</ProjectCard>
|
||||
</div>
|
||||
<div v-else class="error">
|
||||
<UpToDate class="icon" /><br />
|
||||
<span v-if="auth.user && auth.user.id === creator.id" class="preserve-lines text">
|
||||
<IntlFormatted :message-id="messages.noProjectsAuthLabel">
|
||||
<template #create-link="{ children }">
|
||||
<a class="link" @click.prevent="$router.push('/mods')">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<span v-else class="text">{{ formatMessage(messages.noProjectsLabel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CalendarIcon,
|
||||
EditIcon,
|
||||
XIcon,
|
||||
SaveIcon,
|
||||
UploadIcon,
|
||||
TrashIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
GridIcon,
|
||||
ImageIcon,
|
||||
ListIcon,
|
||||
UpdatedIcon,
|
||||
LibraryIcon,
|
||||
BoxIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { PopoutMenu, FileInput, DropdownSelect, Promotion, Avatar, Button } from '@modrinth/ui'
|
||||
|
||||
import WorldIcon from 'assets/images/utils/world.svg'
|
||||
import UpToDate from 'assets/images/illustrations/up_to_date.svg'
|
||||
import { addNotification } from '~/composables/notifs.js'
|
||||
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
||||
import NavRow from '~/components/ui/NavRow.vue'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatCompactNumber = useCompactNumber()
|
||||
|
||||
const messages = defineMessages({
|
||||
collectionDescription: {
|
||||
id: 'collection.description',
|
||||
defaultMessage: '{description} - View the collection {name} by {username} on Modrinth',
|
||||
},
|
||||
collectionLabel: {
|
||||
id: 'collection.label.collection',
|
||||
defaultMessage: 'Collection',
|
||||
},
|
||||
collectionTitle: {
|
||||
id: 'collection.title',
|
||||
defaultMessage: '{name} - Collection',
|
||||
},
|
||||
editIconButton: {
|
||||
id: 'collection.button.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
deleteIconButton: {
|
||||
id: 'collection.button.delete-icon',
|
||||
defaultMessage: 'Delete icon',
|
||||
},
|
||||
createdAtLabel: {
|
||||
id: 'collection.label.created-at',
|
||||
defaultMessage: 'Created {ago}',
|
||||
},
|
||||
collectionNotFoundError: {
|
||||
id: 'collection.error.not-found',
|
||||
defaultMessage: 'Collection not found',
|
||||
},
|
||||
curatedByLabel: {
|
||||
id: 'collection.label.curated-by',
|
||||
defaultMessage: 'Curated by',
|
||||
},
|
||||
deleteModalDescription: {
|
||||
id: 'collection.delete-modal.description',
|
||||
defaultMessage: 'This will remove this collection forever. This action cannot be undone.',
|
||||
},
|
||||
deleteModalTitle: {
|
||||
id: 'collection.delete-modal.title',
|
||||
defaultMessage: 'Are you sure you want to delete this collection?',
|
||||
},
|
||||
followingCollectionDescription: {
|
||||
id: 'collection.description.following',
|
||||
defaultMessage: "Auto-generated collection of all the projects you're following.",
|
||||
},
|
||||
noProjectsLabel: {
|
||||
id: 'collection.label.no-projects',
|
||||
defaultMessage: 'This collection has no projects!',
|
||||
},
|
||||
noProjectsAuthLabel: {
|
||||
id: 'collection.label.no-projects-auth',
|
||||
defaultMessage:
|
||||
"You don't have any projects.\nWould you like to <create-link>add one</create-link>?",
|
||||
},
|
||||
ownerLabel: {
|
||||
id: 'collection.label.owner',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
projectsCountLabel: {
|
||||
id: 'collection.label.projects-count',
|
||||
defaultMessage:
|
||||
'{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}',
|
||||
},
|
||||
removeProjectButton: {
|
||||
id: 'collection.button.remove-project',
|
||||
defaultMessage: 'Remove project',
|
||||
},
|
||||
unfollowProjectButton: {
|
||||
id: 'collection.button.unfollow-project',
|
||||
defaultMessage: 'Unfollow project',
|
||||
},
|
||||
updatedAtLabel: {
|
||||
id: 'collection.label.updated-at',
|
||||
defaultMessage: 'Updated {ago}',
|
||||
},
|
||||
uploadIconButton: {
|
||||
id: 'collection.button.upload-icon',
|
||||
defaultMessage: 'Upload icon',
|
||||
},
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const route = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
const cosmetics = useCosmetics()
|
||||
const tags = useTags()
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
function cycleSearchDisplayMode() {
|
||||
cosmetics.value.searchDisplayMode.collection = data.$cycleValue(
|
||||
cosmetics.value.searchDisplayMode.collection,
|
||||
tags.value.projectViewModes
|
||||
)
|
||||
saveCosmetics()
|
||||
}
|
||||
|
||||
let collection, refreshCollection, creator, projects, refreshProjects
|
||||
|
||||
try {
|
||||
if (route.params.id === 'following') {
|
||||
collection = ref({
|
||||
id: 'following',
|
||||
icon_url: 'https://cdn.modrinth.com/follow-collection.png',
|
||||
name: formatMessage(commonMessages.followedProjectsLabel),
|
||||
description: formatMessage(messages.followingCollectionDescription),
|
||||
status: 'private',
|
||||
user: auth.value.user.id,
|
||||
created: auth.value.user.created,
|
||||
updated: auth.value.user.created,
|
||||
})
|
||||
;[{ data: projects, refresh: refreshProjects }] = await Promise.all([
|
||||
useAsyncData(
|
||||
`user/${auth.value.user.id}/follows`,
|
||||
() => useBaseFetch(`user/${auth.value.user.id}/follows`),
|
||||
{
|
||||
transform: (projects) => {
|
||||
for (const project of projects) {
|
||||
project.categories = project.categories.concat(project.loaders)
|
||||
}
|
||||
|
||||
return projects
|
||||
},
|
||||
}
|
||||
),
|
||||
])
|
||||
creator = ref(auth.value.user)
|
||||
refreshCollection = async () => {}
|
||||
} else {
|
||||
const val = await useAsyncData(`collection/${route.params.id}`, () =>
|
||||
useBaseFetch(`collection/${route.params.id}`, { apiVersion: 3 })
|
||||
)
|
||||
collection = val.data
|
||||
refreshCollection = val.refresh
|
||||
;[{ data: creator }, { data: projects, refresh: refreshProjects }] = await Promise.all([
|
||||
await useAsyncData(`user/${collection.value.user}`, () =>
|
||||
useBaseFetch(`user/${collection.value.user}`)
|
||||
),
|
||||
await useAsyncData(
|
||||
`projects?ids=${encodeURIComponent(JSON.stringify(collection.value.projects))}]`,
|
||||
() =>
|
||||
useBaseFetch(
|
||||
`projects?ids=${encodeURIComponent(JSON.stringify(collection.value.projects))}`
|
||||
),
|
||||
{
|
||||
transform: (projects) => {
|
||||
for (const project of projects) {
|
||||
project.categories = project.categories.concat(project.loaders)
|
||||
}
|
||||
|
||||
return projects
|
||||
},
|
||||
}
|
||||
),
|
||||
])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.collectionNotFoundError),
|
||||
})
|
||||
}
|
||||
|
||||
if (!collection.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.collectionNotFoundError),
|
||||
})
|
||||
}
|
||||
|
||||
const title = computed(() =>
|
||||
formatMessage(messages.collectionTitle, { name: collection.value.name })
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description: () =>
|
||||
formatMessage(messages.collectionDescription, {
|
||||
name: collection.value.name,
|
||||
description: collection.value.description,
|
||||
username: creator.value.username,
|
||||
}),
|
||||
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(
|
||||
() =>
|
||||
auth.value.user &&
|
||||
auth.value.user.id === collection.value.user &&
|
||||
collection.value.id !== 'following'
|
||||
)
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const projectSet = new Set(
|
||||
projects.value?.map((project) => project?.project_type).filter((x) => x !== undefined) || []
|
||||
)
|
||||
projectSet.delete('project')
|
||||
return Array.from(projectSet)
|
||||
})
|
||||
|
||||
const icon = ref(null)
|
||||
const deletedIcon = ref(false)
|
||||
const previewImage = ref(null)
|
||||
|
||||
const name = ref(collection.value.name)
|
||||
const summary = ref(collection.value.description)
|
||||
const visibility = ref(collection.value.status)
|
||||
const removeProjects = ref([])
|
||||
|
||||
async function unfollowProject(project) {
|
||||
await userUnfollowProject(project)
|
||||
projects.value = projects.value.filter((x) => x.id !== project.id)
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
startLoading()
|
||||
try {
|
||||
if (deletedIcon.value) {
|
||||
await useBaseFetch(`collection/${collection.value.id}/icon`, {
|
||||
method: 'DELETE',
|
||||
apiVersion: 3,
|
||||
})
|
||||
} else if (icon.value) {
|
||||
const ext = icon.value?.type?.split('/').pop()
|
||||
if (!ext) throw new Error('Invalid file type')
|
||||
await useBaseFetch(`collection/${collection.value.id}/icon?ext=${ext}`, {
|
||||
method: 'PATCH',
|
||||
body: icon.value,
|
||||
apiVersion: 3,
|
||||
})
|
||||
}
|
||||
|
||||
const projectsToRemove = removeProjects.value?.map((p) => p.id) ?? []
|
||||
const newProjects = projects.value
|
||||
.filter((p) => !projectsToRemove.includes(p.id))
|
||||
.map((p) => p.id)
|
||||
const newProjectIds = projectsToRemove.length > 0 ? newProjects : undefined
|
||||
|
||||
await useBaseFetch(`collection/${collection.value.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: name.value,
|
||||
description: summary.value,
|
||||
status: visibility.value,
|
||||
new_projects: newProjectIds,
|
||||
},
|
||||
apiVersion: 3,
|
||||
})
|
||||
|
||||
await refreshCollection()
|
||||
await refreshProjects()
|
||||
|
||||
name.value = collection.value.name
|
||||
summary.value = collection.value.description
|
||||
visibility.value = collection.value.status
|
||||
removeProjects.value = []
|
||||
|
||||
isEditing.value = false
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
await initUserCollections()
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function deleteCollection() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`collection/${collection.value.id}`, {
|
||||
method: 'DELETE',
|
||||
apiVersion: 3,
|
||||
})
|
||||
await navigateTo('/dashboard/collections')
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
await initUserCollections()
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function 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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.animated-dropdown {
|
||||
// Omorphia's dropdowns are harcoded in width, so we need to override that
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.inputs {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
input {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member {
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
.member-info {
|
||||
overflow: hidden;
|
||||
margin: auto 0 auto 0.75rem;
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--spacing-card-lg);
|
||||
|
||||
.page-header__icon {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.card__overlay {
|
||||
top: var(--spacing-card-lg);
|
||||
right: var(--spacing-card-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-nm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
cursor: default;
|
||||
|
||||
.label {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-heading);
|
||||
margin-bottom: 0.5rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.collection-label {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.collection-description {
|
||||
margin-top: var(--spacing-card-sm);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
63
apps/frontend/src/pages/dashboard.vue
Normal file
63
apps/frontend/src/pages/dashboard.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Dashboard</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/dashboard" label="Overview">
|
||||
<DashboardIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/notifications" label="Notifications">
|
||||
<NotificationsIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/reports" label="Active reports">
|
||||
<ReportIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/analytics" label="Analytics">
|
||||
<ChartIcon />
|
||||
</NavStackItem>
|
||||
|
||||
<h3>Manage</h3>
|
||||
<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="formatMessage(commonMessages.collectionsLabel)"
|
||||
>
|
||||
<LibraryIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/revenue" label="Revenue">
|
||||
<CurrencyIcon />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<NuxtPage :route="route" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { LibraryIcon, ChartIcon } from '@modrinth/assets'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
|
||||
import DashboardIcon from '~/assets/images/utils/dashboard.svg?component'
|
||||
import CurrencyIcon from '~/assets/images/utils/currency.svg?component'
|
||||
import ListIcon from '~/assets/images/utils/list.svg?component'
|
||||
import ReportIcon from '~/assets/images/utils/report.svg?component'
|
||||
import NotificationsIcon from '~/assets/images/utils/bell.svg?component'
|
||||
import OrganizationIcon from '~/assets/images/utils/organization.svg?component'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const route = useNativeRoute()
|
||||
</script>
|
||||
24
apps/frontend/src/pages/dashboard/analytics.vue
Normal file
24
apps/frontend/src/pages/dashboard/analytics.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<ChartDisplay :projects="projects ?? undefined" :personal="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Analytics - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const id = auth.value?.user?.id
|
||||
|
||||
const { data: projects } = await useAsyncData(`user/${id}/projects`, () =>
|
||||
useBaseFetch(`user/${id}/projects`)
|
||||
)
|
||||
</script>
|
||||
235
apps/frontend/src/pages/dashboard/collections.vue
Normal file
235
apps/frontend/src/pages/dashboard/collections.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<CollectionCreateModal ref="modal_creation" />
|
||||
<h2>{{ formatMessage(commonMessages.collectionsLabel) }}</h2>
|
||||
<div class="search-row">
|
||||
<div class="iconified-input">
|
||||
<label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label>
|
||||
<SearchIcon />
|
||||
<input id="search-input" v-model="filterQuery" type="text" />
|
||||
<Button v-if="filterQuery" class="r-btn" @click="() => (filterQuery = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<Button color="primary" @click="$refs.modal_creation.show()">
|
||||
<PlusIcon /> {{ formatMessage(messages.createNewButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="collections-grid">
|
||||
<nuxt-link
|
||||
v-if="'followed projects'.includes(filterQuery)"
|
||||
:to="`/collection/following`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">{{ formatMessage(commonMessages.followedProjectsLabel) }}</span>
|
||||
<span class="description">
|
||||
{{ formatMessage(messages.followingCollectionDescription) }}
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats">
|
||||
<BoxIcon />
|
||||
{{
|
||||
formatMessage(messages.projectsCountLabel, {
|
||||
count: formatCompactNumber(user.follows.length),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<LockIcon /> <span> {{ formatMessage(commonMessages.privateLabel) }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-for="collection in orderedCollections"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar :src="collection.icon_url" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">{{ collection.name }}</span>
|
||||
<span class="description">
|
||||
{{ collection.description }}
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats">
|
||||
<BoxIcon />
|
||||
{{
|
||||
formatMessage(messages.projectsCountLabel, {
|
||||
count: formatCompactNumber(collection.projects?.length || 0),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<WorldIcon />
|
||||
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon />
|
||||
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon />
|
||||
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon />
|
||||
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BoxIcon, SearchIcon, XIcon, PlusIcon, LinkIcon, LockIcon } from '@modrinth/assets'
|
||||
import { Avatar, Button } from '@modrinth/ui'
|
||||
import WorldIcon from '~/assets/images/utils/world.svg?component'
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatCompactNumber = useCompactNumber()
|
||||
|
||||
const messages = defineMessages({
|
||||
createNewButton: {
|
||||
id: 'dashboard.collections.button.create-new',
|
||||
defaultMessage: 'Create new',
|
||||
},
|
||||
collectionsLongTitle: {
|
||||
id: 'dashboard.collections.long-title',
|
||||
defaultMessage: 'Your collections',
|
||||
},
|
||||
followingCollectionDescription: {
|
||||
id: 'collection.description.following',
|
||||
defaultMessage: "Auto-generated collection of all the projects you're following.",
|
||||
},
|
||||
projectsCountLabel: {
|
||||
id: 'dashboard.collections.label.projects-count',
|
||||
defaultMessage: '{count, plural, one {{count} project} other {{count} projects}}',
|
||||
},
|
||||
searchInputLabel: {
|
||||
id: 'dashboard.collections.label.search-input',
|
||||
defaultMessage: 'Search your collections',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.collectionsLongTitle)} - Modrinth`,
|
||||
})
|
||||
|
||||
const user = await useUser()
|
||||
const auth = await useAuth()
|
||||
|
||||
if (process.client) {
|
||||
await initUserFollows()
|
||||
}
|
||||
|
||||
const filterQuery = ref('')
|
||||
|
||||
const { data: collections } = await useAsyncData(`user/${auth.value.user.id}/collections`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 })
|
||||
)
|
||||
|
||||
const orderedCollections = computed(() => {
|
||||
if (!collections.value) return []
|
||||
return collections.value
|
||||
.sort((a, b) => {
|
||||
const aUpdated = new Date(a.updated)
|
||||
const bUpdated = new Date(b.updated)
|
||||
return bUpdated - aUpdated
|
||||
})
|
||||
.filter((collection) => {
|
||||
if (!filterQuery.value) return true
|
||||
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase())
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-bottom: var(--gap-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-lg) var(--gap-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
.iconified-input {
|
||||
flex-grow: 1;
|
||||
|
||||
input {
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
222
apps/frontend/src/pages/dashboard/index.vue
Normal file
222
apps/frontend/src/pages/dashboard/index.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="dashboard-overview">
|
||||
<section class="universal-card dashboard-header">
|
||||
<Avatar :src="auth.user.avatar_url" size="md" circle :alt="auth.user.username" />
|
||||
<div class="username">
|
||||
<h1>
|
||||
{{ auth.user.username }}
|
||||
</h1>
|
||||
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
|
||||
Visit your profile
|
||||
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
<div class="dashboard-notifications">
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title">Notifications</h2>
|
||||
<nuxt-link
|
||||
v-if="notifications.length > 0"
|
||||
class="goto-link"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
See all <ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<template v-if="notifications.length > 0">
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notifications="notifications"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
:auth="auth"
|
||||
raised
|
||||
compact
|
||||
@update:notifications="() => refresh()"
|
||||
/>
|
||||
<nuxt-link
|
||||
v-if="extraNotifs > 0"
|
||||
class="goto-link view-more-notifs"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
View {{ extraNotifs }} more notification{{ extraNotifs === 1 ? '' : 's' }}
|
||||
<ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<div v-else class="universal-body">
|
||||
<p>You have no unread notifications.</p>
|
||||
<nuxt-link class="iconified-button" to="/dashboard/notifications/history">
|
||||
<HistoryIcon /> View notification history
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-analytics">
|
||||
<section class="universal-card">
|
||||
<h2>Analytics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total downloads</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.downloads, 0)) }}
|
||||
</div>
|
||||
<span
|
||||
>from
|
||||
{{ downloadsProjectCount }}
|
||||
project{{ downloadsProjectCount === 1 ? '' : 's' }}</span
|
||||
>
|
||||
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
|
||||
<!-- >View breakdown-->
|
||||
<!-- <ChevronRightIcon-->
|
||||
<!-- class="featured-header-chevron"-->
|
||||
<!-- aria-hidden="true"-->
|
||||
<!-- /></NuxtLink>-->
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total followers</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.followers, 0)) }}
|
||||
</div>
|
||||
<span>
|
||||
<span
|
||||
>from {{ followersProjectCount }} project{{
|
||||
followersProjectCount === 1 ? '' : 's'
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Current balance</div>
|
||||
<div class="value">
|
||||
{{ $formatMoney(auth.user.payout_data.balance, true) }}
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="auth.user.payout_data.balance > 0"
|
||||
class="goto-link"
|
||||
to="/dashboard/revenue"
|
||||
>
|
||||
Withdraw earnings
|
||||
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?component'
|
||||
import HistoryIcon from '~/assets/images/utils/history.svg?component'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import NotificationItem from '~/components/ui/NotificationItem.vue'
|
||||
import { fetchExtraNotificationData, groupNotifications } from '~/helpers/notifications.js'
|
||||
|
||||
useHead({
|
||||
title: 'Dashboard - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const [{ data: projects }] = await Promise.all([
|
||||
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/projects`)
|
||||
),
|
||||
])
|
||||
|
||||
const downloadsProjectCount = computed(
|
||||
() => projects.value.filter((project) => project.downloads > 0).length
|
||||
)
|
||||
const followersProjectCount = computed(
|
||||
() => projects.value.filter((project) => project.followers > 0).length
|
||||
)
|
||||
|
||||
const { data, refresh } = await useAsyncData(async () => {
|
||||
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
|
||||
|
||||
const filteredNotifications = notifications.filter((notif) => !notif.read)
|
||||
const slice = filteredNotifications.slice(0, 30) // send first 30 notifs to be grouped before trimming to 3
|
||||
|
||||
return fetchExtraNotificationData(slice).then((notifications) => {
|
||||
notifications = groupNotifications(notifications).slice(0, 3)
|
||||
return { notifications, extraNotifs: filteredNotifications.length - slice.length }
|
||||
})
|
||||
})
|
||||
|
||||
const notifications = computed(() => {
|
||||
if (data.value === null) {
|
||||
return []
|
||||
}
|
||||
return data.value.notifications
|
||||
})
|
||||
|
||||
const extraNotifs = computed(() => (data.value ? data.value.extraNotifs : 0))
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.dashboard-overview {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'header header'
|
||||
'notifications analytics' / 1fr auto;
|
||||
gap: var(--spacing-card-md);
|
||||
|
||||
> .universal-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-notifications {
|
||||
grid-area: notifications;
|
||||
//display: flex;
|
||||
//flex-direction: column;
|
||||
//gap: var(--spacing-card-md);
|
||||
|
||||
a.view-more-notifs {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-analytics {
|
||||
grid-area: analytics;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
gap: var(--spacing-card-bg);
|
||||
grid-area: header;
|
||||
|
||||
.username {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
justify-content: center;
|
||||
word-break: break-word;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.avatar {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
176
apps/frontend/src/pages/dashboard/notifications.vue
Normal file
176
apps/frontend/src/pages/dashboard/notifications.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<Breadcrumbs
|
||||
v-if="history"
|
||||
current-title="History"
|
||||
:link-stack="[{ href: `/dashboard/notifications`, label: 'Notifications' }]"
|
||||
/>
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2 v-if="history">Notification history</h2>
|
||||
<h2 v-else>Notifications</h2>
|
||||
</div>
|
||||
<template v-if="!history">
|
||||
<Button v-if="hasRead" @click="updateRoute()"> <HistoryIcon /> View history </Button>
|
||||
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
|
||||
<CheckCheckIcon /> Mark all as read
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<Chips
|
||||
v-if="notifTypes.length > 1"
|
||||
v-model="selectedType"
|
||||
:items="notifTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x).replace('_', ' ') + 's')"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<p v-if="pending">Loading notifications...</p>
|
||||
<template v-else-if="error">
|
||||
<p>Error loading notifications:</p>
|
||||
<pre>
|
||||
{{ error }}
|
||||
</pre>
|
||||
</template>
|
||||
<template v-else-if="notifications && notifications.length > 0">
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notifications="notifications"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
:auth="auth"
|
||||
raised
|
||||
@update:notifications="() => refresh()"
|
||||
/>
|
||||
</template>
|
||||
<p v-else>You don't have any unread notifications.</p>
|
||||
<Pagination :page="page" :count="pages" @switch-page="changePage" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button } from '@modrinth/ui'
|
||||
import { HistoryIcon } from '@modrinth/assets'
|
||||
import {
|
||||
fetchExtraNotificationData,
|
||||
groupNotifications,
|
||||
markAsRead,
|
||||
} from '~/helpers/notifications.js'
|
||||
import NotificationItem from '~/components/ui/NotificationItem.vue'
|
||||
import Chips from '~/components/ui/Chips.vue'
|
||||
import CheckCheckIcon from '~/assets/images/utils/check-check.svg?component'
|
||||
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Notifications - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const route = useNativeRoute()
|
||||
const router = useNativeRouter()
|
||||
|
||||
const history = computed(() => {
|
||||
return route.name === 'dashboard-notifications-history'
|
||||
})
|
||||
|
||||
const selectedType = ref('all')
|
||||
const page = ref(1)
|
||||
|
||||
const perPage = ref(50)
|
||||
|
||||
const { data, pending, error, refresh } = await useAsyncData(
|
||||
async () => {
|
||||
const pageNum = page.value - 1
|
||||
|
||||
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
|
||||
const showRead = history.value
|
||||
const hasRead = notifications.some((notif) => notif.read)
|
||||
|
||||
const types = [
|
||||
...new Set(
|
||||
notifications
|
||||
.filter((notification) => {
|
||||
return showRead || !notification.read
|
||||
})
|
||||
.map((notification) => notification.type)
|
||||
),
|
||||
]
|
||||
|
||||
const filteredNotifications = notifications.filter(
|
||||
(notification) =>
|
||||
(selectedType.value === 'all' || notification.type === selectedType.value) &&
|
||||
(showRead || !notification.read)
|
||||
)
|
||||
const pages = Math.ceil(filteredNotifications.length / perPage.value)
|
||||
|
||||
return fetchExtraNotificationData(
|
||||
filteredNotifications.slice(pageNum * perPage.value, perPage.value + pageNum * perPage.value)
|
||||
).then((notifications) => {
|
||||
return {
|
||||
notifications,
|
||||
types: types.length > 1 ? ['all', ...types] : types,
|
||||
pages,
|
||||
hasRead,
|
||||
}
|
||||
})
|
||||
},
|
||||
{ watch: [page, history, selectedType] }
|
||||
)
|
||||
|
||||
const notifications = computed(() => {
|
||||
if (data.value === null) {
|
||||
return []
|
||||
}
|
||||
return groupNotifications(data.value.notifications, history.value)
|
||||
})
|
||||
const notifTypes = computed(() => data.value.types)
|
||||
const pages = computed(() => data.value.pages)
|
||||
const hasRead = computed(() => data.value.hasRead)
|
||||
|
||||
function updateRoute() {
|
||||
if (history.value) {
|
||||
router.push('/dashboard/notifications')
|
||||
} else {
|
||||
router.push('/dashboard/notifications/history')
|
||||
}
|
||||
selectedType.value = 'all'
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
const ids = notifications.value.flatMap((notification) => [
|
||||
notification.id,
|
||||
...(notification.grouped_notifs ? notification.grouped_notifs.map((notif) => notif.id) : []),
|
||||
])
|
||||
|
||||
const updateNotifs = await markAsRead(ids)
|
||||
allNotifs.value = updateNotifs(allNotifs.value)
|
||||
}
|
||||
|
||||
function changePage(newPage) {
|
||||
page.value = newPage
|
||||
if (process.client) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.read-toggle-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-md);
|
||||
|
||||
.label__title {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header__title {
|
||||
h2 {
|
||||
margin: 0 auto 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
204
apps/frontend/src/pages/dashboard/organizations.vue
Normal file
204
apps/frontend/src/pages/dashboard/organizations.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<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': onlyAcceptedMembers(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 />
|
||||
<span>
|
||||
{{ onlyAcceptedMembers(org.members).length }} member<template
|
||||
v-if="onlyAcceptedMembers(org.members).length !== 1"
|
||||
>s</template
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else> Make an organization! </template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { PlusIcon, UsersIcon } from '@modrinth/assets'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
const onlyAcceptedMembers = (members) => members.filter((member) => member?.accepted)
|
||||
|
||||
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: max-content 1fr;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: 0;
|
||||
|
||||
.icon {
|
||||
width: 100% !important;
|
||||
height: min(6rem, 20vw) !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>
|
||||
674
apps/frontend/src/pages/dashboard/projects.vue
Normal file
674
apps/frontend/src/pages/dashboard/projects.vue
Normal file
@@ -0,0 +1,674 @@
|
||||
<template>
|
||||
<div>
|
||||
<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"
|
||||
@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"
|
||||
class="square-button label-button"
|
||||
:data-active="editLinks.source.clear"
|
||||
@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"
|
||||
class="square-button label-button"
|
||||
:data-active="editLinks.wiki.clear"
|
||||
@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"
|
||||
class="square-button label-button"
|
||||
:data-active="editLinks.discord.clear"
|
||||
@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.title }}
|
||||
</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 class="iconified-button" @click="$refs.editLinksModal.hide()">
|
||||
<CrossIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button brand-button" @click="bulkEditLinks()">
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title">Projects</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(commonMessages.createAProjectButton) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="projects.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
|
||||
class="iconified-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="projects = updateSort(projects, sortBy, descending)"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="descending ? 'Descending' : 'Ascending'"
|
||||
class="square-button"
|
||||
@click="updateDescending()"
|
||||
>
|
||||
<DescendingIcon v-if="descending" />
|
||||
<AscendingIcon v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-table">
|
||||
<div class="grid-table__row grid-table__header">
|
||||
<div>
|
||||
<Checkbox
|
||||
:model-value="selectedProjects === projects"
|
||||
@update:model-value="
|
||||
selectedProjects === projects
|
||||
? (selectedProjects = [])
|
||||
: (selectedProjects = projects)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>Icon</div>
|
||||
<div>Name</div>
|
||||
<div>ID</div>
|
||||
<div>Type</div>
|
||||
<div>Status</div>
|
||||
<div />
|
||||
</div>
|
||||
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
|
||||
<div>
|
||||
<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>
|
||||
<nuxt-link
|
||||
tabindex="-1"
|
||||
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="project.icon_url"
|
||||
aria-hidden="true"
|
||||
:alt="'Icon for ' + project.title"
|
||||
no-shadow
|
||||
/>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}`"
|
||||
>
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CopyCode :text="project.id" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ $formatProjectType($getProjectTypeForUrl(project.project_type, project.loaders)) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Badge v-if="project.status" :type="project.status" class="status" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<nuxt-link
|
||||
class="square-button"
|
||||
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/settings`"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import Checkbox from '~/components/ui/Checkbox.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
|
||||
import SettingsIcon from '~/assets/images/utils/settings.svg?component'
|
||||
import TrashIcon from '~/assets/images/utils/trash.svg?component'
|
||||
import IssuesIcon from '~/assets/images/utils/issues.svg?component'
|
||||
import PlusIcon from '~/assets/images/utils/plus.svg?component'
|
||||
import CrossIcon from '~/assets/images/utils/x.svg?component'
|
||||
import EditIcon from '~/assets/images/utils/edit.svg?component'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg?component'
|
||||
import AscendingIcon from '~/assets/images/utils/sort-asc.svg?component'
|
||||
import DescendingIcon from '~/assets/images/utils/sort-desc.svg?component'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Avatar,
|
||||
Badge,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
Checkbox,
|
||||
IssuesIcon,
|
||||
PlusIcon,
|
||||
CrossIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
Modal,
|
||||
ModalCreation,
|
||||
Multiselect,
|
||||
CopyCode,
|
||||
AscendingIcon,
|
||||
DescendingIcon,
|
||||
},
|
||||
async setup() {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const user = await useUser()
|
||||
await initUserProjects()
|
||||
return { formatMessage, user: ref(user) }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projects: this.updateSort(this.user.projects, 'Name'),
|
||||
versions: [],
|
||||
selectedProjects: [],
|
||||
sortBy: 'Name',
|
||||
descending: false,
|
||||
editLinks: {
|
||||
showAffected: false,
|
||||
source: {
|
||||
val: '',
|
||||
clear: false,
|
||||
},
|
||||
discord: {
|
||||
val: '',
|
||||
clear: false,
|
||||
},
|
||||
wiki: {
|
||||
val: '',
|
||||
clear: false,
|
||||
},
|
||||
issues: {
|
||||
val: '',
|
||||
clear: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
head: {
|
||||
title: 'Projects - Modrinth',
|
||||
},
|
||||
created() {
|
||||
this.UPLOAD_VERSION = 1 << 0
|
||||
this.DELETE_VERSION = 1 << 1
|
||||
this.EDIT_DETAILS = 1 << 2
|
||||
this.EDIT_BODY = 1 << 3
|
||||
this.MANAGE_INVITES = 1 << 4
|
||||
this.REMOVE_MEMBER = 1 << 5
|
||||
this.EDIT_MEMBER = 1 << 6
|
||||
this.DELETE_PROJECT = 1 << 7
|
||||
},
|
||||
methods: {
|
||||
updateDescending() {
|
||||
this.descending = !this.descending
|
||||
this.projects = this.updateSort(this.projects, this.sortBy, this.descending)
|
||||
},
|
||||
updateSort(projects, sort, descending) {
|
||||
let sortedArray = projects
|
||||
switch (sort) {
|
||||
case 'Name':
|
||||
sortedArray = projects.slice().sort((a, b) => {
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
break
|
||||
case 'Status':
|
||||
sortedArray = projects.slice().sort((a, b) => {
|
||||
if (a.status < b.status) {
|
||||
return -1
|
||||
}
|
||||
if (a.status > b.status) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
break
|
||||
case 'Type':
|
||||
sortedArray = projects.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
|
||||
},
|
||||
async bulkEditLinks() {
|
||||
try {
|
||||
const baseData = {
|
||||
issues_url: this.editLinks.issues.clear ? null : this.editLinks.issues.val.trim(),
|
||||
source_url: this.editLinks.source.clear ? null : this.editLinks.source.val.trim(),
|
||||
wiki_url: this.editLinks.wiki.clear ? null : this.editLinks.wiki.val.trim(),
|
||||
discord_url: this.editLinks.discord.clear ? null : this.editLinks.discord.val.trim(),
|
||||
}
|
||||
|
||||
if (!baseData.issues_url?.length ?? 1 > 0) {
|
||||
delete baseData.issues_url
|
||||
}
|
||||
|
||||
if (!baseData.source_url?.length ?? 1 > 0) {
|
||||
delete baseData.source_url
|
||||
}
|
||||
|
||||
if (!baseData.wiki_url?.length ?? 1 > 0) {
|
||||
delete baseData.wiki_url
|
||||
}
|
||||
|
||||
if (!baseData.discord_url?.length ?? 1 > 0) {
|
||||
delete baseData.discord_url
|
||||
}
|
||||
|
||||
await useBaseFetch(
|
||||
`projects?ids=${JSON.stringify(this.selectedProjects.map((x) => x.id))}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: baseData,
|
||||
}
|
||||
)
|
||||
|
||||
this.$refs.editLinksModal.hide()
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'Success',
|
||||
text: "Bulk edited selected project's links.",
|
||||
type: 'success',
|
||||
})
|
||||
this.selectedProjects = []
|
||||
|
||||
this.editLinks.issues.val = ''
|
||||
this.editLinks.source.val = ''
|
||||
this.editLinks.wiki.val = ''
|
||||
this.editLinks.discord.val = ''
|
||||
this.editLinks.issues.clear = false
|
||||
this.editLinks.source.clear = false
|
||||
this.editLinks.wiki.clear = false
|
||||
this.editLinks.discord.clear = false
|
||||
} catch (e) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: e,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.grid-table {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
min-content min-content minmax(min-content, 2fr)
|
||||
minmax(min-content, 1fr) minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
|
||||
border-radius: var(--size-rounded-sm);
|
||||
overflow: hidden;
|
||||
margin-top: var(--spacing-card-md);
|
||||
outline: 1px solid transparent;
|
||||
|
||||
.grid-table__row {
|
||||
display: contents;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.grid-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;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-table__header {
|
||||
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) {
|
||||
.grid-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;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-table__header {
|
||||
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-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(--spacing-card-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.small-select {
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.label-button[data-active='true'] {
|
||||
--background-color: var(--color-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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
apps/frontend/src/pages/dashboard/report/[id].vue
Normal file
17
apps/frontend/src/pages/dashboard/report/[id].vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<ReportView
|
||||
:auth="auth"
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/dashboard/reports', label: 'Active reports' }]"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportView from '~/components/ui/report/ReportView.vue'
|
||||
|
||||
const route = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
16
apps/frontend/src/pages/dashboard/reports.vue
Normal file
16
apps/frontend/src/pages/dashboard/reports.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Reports</h2>
|
||||
<ReportsList :auth="auth" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportsList from '~/components/ui/report/ReportsList.vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
useHead({
|
||||
title: 'Active reports - Modrinth',
|
||||
})
|
||||
</script>
|
||||
113
apps/frontend/src/pages/dashboard/revenue/index.vue
Normal file
113
apps/frontend/src/pages/dashboard/revenue/index.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Revenue</h2>
|
||||
<div v-if="auth.user.payout_data.balance >= minWithdraw">
|
||||
<p>
|
||||
You have
|
||||
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong>
|
||||
available to withdraw.
|
||||
</p>
|
||||
</div>
|
||||
<p v-else>
|
||||
You have made
|
||||
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong
|
||||
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<nuxt-link
|
||||
v-if="auth.user.payout_data.balance >= minWithdraw"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon /> View transfer history
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p>
|
||||
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
|
||||
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more
|
||||
information on how the rewards system works, see our information page
|
||||
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>.
|
||||
</p>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2>Payout methods</h2>
|
||||
<h3>PayPal</h3>
|
||||
<template v-if="auth.user.auth_providers.includes('paypal')">
|
||||
<p>
|
||||
Your PayPal {{ auth.user.payout_data.paypal_country }} account is currently connected with
|
||||
email
|
||||
{{ auth.user.payout_data.paypal_address }}
|
||||
</p>
|
||||
<button class="btn" @click="removeAuthProvider('paypal')">
|
||||
<XIcon /> Disconnect account
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
|
||||
<a class="btn" :href="`${getAuthUrl('paypal')}&token=${auth.token}`">
|
||||
<PayPalIcon />
|
||||
Sign in with PayPal
|
||||
</a>
|
||||
</template>
|
||||
<h3>Tremendous</h3>
|
||||
<p>
|
||||
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
|
||||
visit
|
||||
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>.
|
||||
</p>
|
||||
<h3>Venmo</h3>
|
||||
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
|
||||
<label class="hidden" for="venmo">Venmo address</label>
|
||||
<input
|
||||
id="venmo"
|
||||
v-model="auth.user.payout_data.venmo_handle"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="@example"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from '@modrinth/assets'
|
||||
|
||||
const auth = await useAuth()
|
||||
const minWithdraw = ref(0.01)
|
||||
|
||||
async function updateVenmo() {
|
||||
startLoading()
|
||||
try {
|
||||
const data = {
|
||||
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
|
||||
}
|
||||
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
apiVersion: 3,
|
||||
})
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
const data = useNuxtApp()
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
strong {
|
||||
color: var(--color-text-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
227
apps/frontend/src/pages/dashboard/revenue/transfers.vue
Normal file
227
apps/frontend/src/pages/dashboard/revenue/transfers.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card payout-history">
|
||||
<Breadcrumbs
|
||||
current-title="Transfer history"
|
||||
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
|
||||
/>
|
||||
<h2>Transfer history</h2>
|
||||
<p>All of your withdrawals from your Modrinth balance will be listed here:</p>
|
||||
<div class="input-group">
|
||||
<DropdownSelect
|
||||
v-model="selectedYear"
|
||||
:options="years"
|
||||
:display-name="(x) => (x === 'all' ? 'All years' : x)"
|
||||
name="Year filter"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-model="selectedMethod"
|
||||
:options="methods"
|
||||
:display-name="
|
||||
(x) => (x === 'all' ? 'Any method' : x === 'paypal' ? 'PayPal' : capitalizeString(x))
|
||||
"
|
||||
name="Method filter"
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
{{
|
||||
selectedYear !== 'all'
|
||||
? selectedMethod !== 'all'
|
||||
? formatMessage(messages.transfersTotalYearMethod, {
|
||||
amount: $formatMoney(totalAmount),
|
||||
year: selectedYear,
|
||||
method: selectedMethod,
|
||||
})
|
||||
: formatMessage(messages.transfersTotalYear, {
|
||||
amount: $formatMoney(totalAmount),
|
||||
year: selectedYear,
|
||||
})
|
||||
: selectedMethod !== 'all'
|
||||
? formatMessage(messages.transfersTotalMethod, {
|
||||
amount: $formatMoney(totalAmount),
|
||||
method: selectedMethod,
|
||||
})
|
||||
: formatMessage(messages.transfersTotal, { amount: $formatMoney(totalAmount) })
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
v-for="payout in filteredPayouts"
|
||||
:key="payout.id"
|
||||
class="universal-card recessed payout"
|
||||
>
|
||||
<div class="platform">
|
||||
<PayPalIcon v-if="payout.method === 'paypal'" />
|
||||
<TremendousIcon v-else-if="payout.method === 'tremendous'" />
|
||||
<VenmoIcon v-else-if="payout.method === 'venmo'" />
|
||||
<UnknownIcon v-else />
|
||||
</div>
|
||||
<div class="payout-info">
|
||||
<div>
|
||||
<strong>
|
||||
{{ $dayjs(payout.created).format('MMMM D, YYYY [at] h:mm A') }}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="amount">{{ $formatMoney(payout.amount) }}</span>
|
||||
<template v-if="payout.fee">⋅ Fee {{ $formatMoney(payout.fee) }}</template>
|
||||
</div>
|
||||
<div class="payout-status">
|
||||
<span>
|
||||
<Badge v-if="payout.status === 'success'" color="green" type="Success" />
|
||||
<Badge v-else-if="payout.status === 'cancelling'" color="yellow" type="Cancelling" />
|
||||
<Badge v-else-if="payout.status === 'cancelled'" color="red" type="Cancelled" />
|
||||
<Badge v-else-if="payout.status === 'failed'" color="red" type="Failed" />
|
||||
<Badge v-else-if="payout.status === 'in-transit'" color="yellow" type="In transit" />
|
||||
<Badge v-else :type="payout.status" />
|
||||
</span>
|
||||
<template v-if="payout.method">
|
||||
<span>⋅</span>
|
||||
<span>{{ $formatWallet(payout.method) }} ({{ payout.method_address }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-if="payout.status === 'in-transit'"
|
||||
class="iconified-button raised-button"
|
||||
@click="cancelPayout(payout.id)"
|
||||
>
|
||||
<XIcon /> Cancel payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { DropdownSelect } from '@modrinth/ui'
|
||||
import { XIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { Badge, Breadcrumbs } from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import TremendousIcon from '~/assets/images/external/tremendous.svg?component'
|
||||
import VenmoIcon from '~/assets/images/external/venmo-small.svg?component'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
useHead({
|
||||
title: 'Transfer history - Modrinth',
|
||||
})
|
||||
|
||||
const data = await useNuxtApp()
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: payouts, refresh } = await useAsyncData(`payout`, () =>
|
||||
useBaseFetch(`payout`, {
|
||||
apiVersion: 3,
|
||||
})
|
||||
)
|
||||
|
||||
const sortedPayouts = computed(() =>
|
||||
payouts.value.sort((a, b) => dayjs(b.created) - dayjs(a.created))
|
||||
)
|
||||
|
||||
const years = computed(() => {
|
||||
const values = sortedPayouts.value.map((x) => dayjs(x.created).year())
|
||||
return ['all', ...new Set(values)]
|
||||
})
|
||||
|
||||
const selectedYear = ref('all')
|
||||
|
||||
const methods = computed(() => {
|
||||
const values = sortedPayouts.value.filter((x) => x.method).map((x) => x.method)
|
||||
return ['all', ...new Set(values)]
|
||||
})
|
||||
|
||||
const selectedMethod = ref('all')
|
||||
|
||||
const filteredPayouts = computed(() =>
|
||||
sortedPayouts.value
|
||||
.filter((x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value)
|
||||
.filter((x) => selectedMethod.value === 'all' || x.method === selectedMethod.value)
|
||||
)
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
filteredPayouts.value.reduce((sum, payout) => sum + payout.amount, 0)
|
||||
)
|
||||
|
||||
async function cancelPayout(id) {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`payout/${id}`, {
|
||||
method: 'DELETE',
|
||||
apiVersion: 3,
|
||||
})
|
||||
await refresh()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
transfersTotal: {
|
||||
id: 'revenue.transfers.total',
|
||||
defaultMessage: 'You have withdrawn {amount} in total.',
|
||||
},
|
||||
transfersTotalYear: {
|
||||
id: 'revenue.transfers.total.year',
|
||||
defaultMessage: 'You have withdrawn {amount} in {year}.',
|
||||
},
|
||||
transfersTotalMethod: {
|
||||
id: 'revenue.transfers.total.method',
|
||||
defaultMessage: 'You have withdrawn {amount} through {method}.',
|
||||
},
|
||||
transfersTotalYearMethod: {
|
||||
id: 'revenue.transfers.total.year_method',
|
||||
defaultMessage: 'You have withdrawn {amount} in {year} through {method}.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.payout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.platform {
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--color-raised-bg);
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
border-radius: 20rem;
|
||||
|
||||
svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.payout-status {
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: var(--color-heading);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
531
apps/frontend/src/pages/dashboard/revenue/withdraw.vue
Normal file
531
apps/frontend/src/pages/dashboard/revenue/withdraw.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<section class="universal-card">
|
||||
<Breadcrumbs
|
||||
current-title="Withdraw"
|
||||
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
|
||||
/>
|
||||
|
||||
<h2>Withdraw</h2>
|
||||
|
||||
<h3>Region</h3>
|
||||
<Multiselect
|
||||
id="country-multiselect"
|
||||
v-model="country"
|
||||
class="country-multiselect"
|
||||
placeholder="Select country..."
|
||||
track-by="id"
|
||||
label="name"
|
||||
:options="countries"
|
||||
:searchable="true"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
|
||||
<h3>Withdraw method</h3>
|
||||
|
||||
<div class="iconified-input">
|
||||
<label class="hidden" for="search">Search</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search"
|
||||
v-model="search"
|
||||
name="search"
|
||||
placeholder="Search options..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="withdraw-options-scroll">
|
||||
<div class="withdraw-options">
|
||||
<button
|
||||
v-for="method in payoutMethods.filter((x) =>
|
||||
x.name.toLowerCase().includes(search.toLowerCase())
|
||||
)"
|
||||
:key="method.id"
|
||||
class="withdraw-option button-base"
|
||||
:class="{ selected: selectedMethodId === method.id }"
|
||||
@click="() => (selectedMethodId = method.id)"
|
||||
>
|
||||
<div class="preview" :class="{ 'show-bg': !method.image_url || method.name === 'ACH' }">
|
||||
<template v-if="method.image_url && method.name !== 'ACH'">
|
||||
<div class="preview-badges">
|
||||
<span class="badge">
|
||||
{{
|
||||
getRangeOfMethod(method)
|
||||
.map($formatMoney)
|
||||
.map((i) => i.replace('.00', ''))
|
||||
.join('–')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
v-if="method.image_url && method.name !== 'ACH'"
|
||||
class="preview-img"
|
||||
:src="method.image_url"
|
||||
:alt="method.name"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="placeholder">
|
||||
<template v-if="method.type === 'venmo'">
|
||||
<VenmoIcon class="enlarge" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<PayPalIcon v-if="method.type === 'paypal'" />
|
||||
<span>{{ method.name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked v-if="selectedMethodId === method.id" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
<span>{{ method.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Amount</h3>
|
||||
<p>
|
||||
You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
|
||||
How much of your
|
||||
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong> balance would you like to
|
||||
transfer to {{ selectedMethod.name }}?
|
||||
</p>
|
||||
<div class="confirmation-input">
|
||||
<template v-if="selectedMethod.interval.fixed">
|
||||
<Chips
|
||||
v-model="amount"
|
||||
:items="selectedMethod.interval.fixed.values"
|
||||
:format-label="(val) => '$' + val"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="minWithdrawAmount == maxWithdrawAmount">
|
||||
<div>
|
||||
<p>
|
||||
This method has a fixed transfer amount of
|
||||
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<p>
|
||||
This method has a minimum transfer amount of
|
||||
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong> and a maximum transfer amount of
|
||||
<strong>{{ $formatMoney(maxWithdrawAmount) }}</strong
|
||||
>.
|
||||
</p>
|
||||
<input
|
||||
id="confirmation"
|
||||
v-model="amount"
|
||||
type="text"
|
||||
pattern="^\d*(\.\d{0,2})?$"
|
||||
autocomplete="off"
|
||||
placeholder="Amount to transfer..."
|
||||
/>
|
||||
<p>
|
||||
You have entered <strong>{{ $formatMoney(parsedAmount) }}</strong> to transfer.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="confirm-text">
|
||||
<template v-if="knownErrors.length === 0 && amount">
|
||||
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
|
||||
I acknowledge that an estimated
|
||||
{{ $formatMoney(fees) }} will be deducted from the amount I receive to cover
|
||||
{{ $formatWallet(selectedMethod.type) }} processing fees.
|
||||
</Checkbox>
|
||||
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
|
||||
<template v-if="selectedMethod.type === 'tremendous'">
|
||||
I confirm that I am initiating a transfer and I will receive further instructions on how
|
||||
to redeem this payment via email to: {{ withdrawAccount }}
|
||||
</template>
|
||||
<template v-else>
|
||||
I confirm that I am initiating a transfer to the following
|
||||
{{ $formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
|
||||
</template>
|
||||
</Checkbox>
|
||||
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
|
||||
I agree to the
|
||||
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>
|
||||
</Checkbox>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-for="(error, index) in knownErrors" :key="index" class="invalid">
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<nuxt-link to="/dashboard/revenue" class="iconified-button">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</nuxt-link>
|
||||
<button
|
||||
:disabled="
|
||||
knownErrors.length > 0 ||
|
||||
!amount ||
|
||||
!agreedTransfer ||
|
||||
!agreedTerms ||
|
||||
(fees > 0 && !agreedFees)
|
||||
"
|
||||
class="iconified-button brand-button"
|
||||
@click="withdraw"
|
||||
>
|
||||
<TransferIcon />
|
||||
Withdraw
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
import {
|
||||
PayPalIcon,
|
||||
SearchIcon,
|
||||
RadioButtonIcon,
|
||||
RadioButtonChecked,
|
||||
XIcon,
|
||||
TransferIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Chips, Checkbox, Breadcrumbs } from '@modrinth/ui'
|
||||
import { all } from 'iso-3166-1'
|
||||
import VenmoIcon from '~/assets/images/external/venmo.svg?component'
|
||||
|
||||
const auth = await useAuth()
|
||||
const data = useNuxtApp()
|
||||
|
||||
const countries = computed(() =>
|
||||
all().map((x) => ({
|
||||
id: x.alpha2,
|
||||
name: x.alpha2 === 'TW' ? 'Taiwan' : x.country,
|
||||
}))
|
||||
)
|
||||
const search = ref('')
|
||||
|
||||
const amount = ref('')
|
||||
const country = ref(
|
||||
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? 'US'))
|
||||
)
|
||||
|
||||
const { data: payoutMethods, refresh: refreshPayoutMethods } = await useAsyncData(
|
||||
`payout/methods?country=${country.value.id}`,
|
||||
() => useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 })
|
||||
)
|
||||
|
||||
const selectedMethodId = ref(payoutMethods.value[0].id)
|
||||
const selectedMethod = computed(() =>
|
||||
payoutMethods.value.find((x) => x.id === selectedMethodId.value)
|
||||
)
|
||||
|
||||
const parsedAmount = computed(() => {
|
||||
const regex = /^\$?(\d*(\.\d{2})?)$/gm
|
||||
const matches = regex.exec(amount.value)
|
||||
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
|
||||
})
|
||||
const fees = computed(() => {
|
||||
return Math.min(
|
||||
Math.max(
|
||||
selectedMethod.value.fee.min,
|
||||
selectedMethod.value.fee.percentage * parsedAmount.value
|
||||
),
|
||||
selectedMethod.value.fee.max ?? Number.MAX_VALUE
|
||||
)
|
||||
})
|
||||
|
||||
const getIntervalRange = (intervalType) => {
|
||||
if (!intervalType) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { min, max, values } = intervalType
|
||||
if (values) {
|
||||
const first = values[0]
|
||||
const last = values.slice(-1)[0]
|
||||
return first === last ? [first] : [first, last]
|
||||
}
|
||||
|
||||
return min === max ? [min] : [min, max]
|
||||
}
|
||||
|
||||
const getRangeOfMethod = (method) => {
|
||||
return getIntervalRange(method.interval?.fixed || method.interval?.standard)
|
||||
}
|
||||
|
||||
const maxWithdrawAmount = computed(() => {
|
||||
const interval = selectedMethod.value.interval
|
||||
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0
|
||||
})
|
||||
|
||||
const minWithdrawAmount = computed(() => {
|
||||
const interval = selectedMethod.value.interval
|
||||
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value
|
||||
})
|
||||
|
||||
const withdrawAccount = computed(() => {
|
||||
if (selectedMethod.value.type === 'paypal') {
|
||||
return auth.value.user.payout_data.paypal_address
|
||||
} else if (selectedMethod.value.type === 'venmo') {
|
||||
return auth.value.user.payout_data.venmo_handle
|
||||
} else {
|
||||
return auth.value.user.email
|
||||
}
|
||||
})
|
||||
const knownErrors = computed(() => {
|
||||
const errors = []
|
||||
if (selectedMethod.value.type === 'paypal' && !auth.value.user.payout_data.paypal_address) {
|
||||
errors.push('Please link your PayPal account in the dashboard to proceed.')
|
||||
}
|
||||
if (selectedMethod.value.type === 'venmo' && !auth.value.user.payout_data.venmo_handle) {
|
||||
errors.push('Please set your Venmo handle in the dashboard to proceed.')
|
||||
}
|
||||
if (selectedMethod.value.type === 'tremendous') {
|
||||
if (!auth.value.user.email) {
|
||||
errors.push('Please set your email address in your account settings to proceed.')
|
||||
}
|
||||
if (!auth.value.user.email_verified) {
|
||||
errors.push('Please verify your email address to proceed.')
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedAmount.value && amount.value.length > 0) {
|
||||
errors.push(`${amount.value} is not a valid amount`)
|
||||
} else if (
|
||||
parsedAmount.value > auth.value.user.payout_data.balance ||
|
||||
parsedAmount.value > maxWithdrawAmount.value
|
||||
) {
|
||||
const maxAmount = Math.min(auth.value.user.payout_data.balance, maxWithdrawAmount.value)
|
||||
errors.push(`The amount must be no more than ${data.$formatMoney(maxAmount)}`)
|
||||
} else if (parsedAmount.value <= fees.value || parsedAmount.value < minWithdrawAmount.value) {
|
||||
const minAmount = Math.max(fees.value + 0.01, minWithdrawAmount.value)
|
||||
errors.push(`The amount must be at least ${data.$formatMoney(minAmount)}`)
|
||||
}
|
||||
|
||||
return errors
|
||||
})
|
||||
|
||||
const agreedTransfer = ref(false)
|
||||
const agreedFees = ref(false)
|
||||
const agreedTerms = ref(false)
|
||||
|
||||
watch(country, async () => {
|
||||
await refreshPayoutMethods()
|
||||
if (payoutMethods.value && payoutMethods.value[0]) {
|
||||
selectedMethodId.value = payoutMethods.value[0].id
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedMethod, () => {
|
||||
if (selectedMethod.value.interval?.fixed) {
|
||||
amount.value = selectedMethod.value.interval.fixed.values[0]
|
||||
}
|
||||
if (maxWithdrawAmount.value === minWithdrawAmount.value) {
|
||||
amount.value = maxWithdrawAmount.value
|
||||
}
|
||||
agreedTransfer.value = false
|
||||
agreedFees.value = false
|
||||
agreedTerms.value = false
|
||||
})
|
||||
|
||||
async function withdraw() {
|
||||
startLoading()
|
||||
try {
|
||||
const auth = await useAuth()
|
||||
|
||||
await useBaseFetch(`payout`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
amount: parsedAmount.value,
|
||||
method: selectedMethod.value.type,
|
||||
method_id: selectedMethod.value.id,
|
||||
},
|
||||
apiVersion: 3,
|
||||
})
|
||||
await useAuth(auth.value.token)
|
||||
await navigateTo('/dashboard/revenue')
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'Withdrawal complete',
|
||||
text:
|
||||
selectedMethod.value.type === 'tremendous'
|
||||
? 'An email has been sent to your account with further instructions on how to redeem your payout!'
|
||||
: `Payment has been sent to your ${data.$formatWallet(
|
||||
selectedMethod.value.type
|
||||
)} account!`,
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.withdraw-options-scroll {
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: var(--gap-md);
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-bg);
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.withdraw-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: var(--gap-lg);
|
||||
padding-right: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 300px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.withdraw-option {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-divider);
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-text);
|
||||
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
|
||||
.label svg {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
aspect-ratio: 30 / 19;
|
||||
position: relative;
|
||||
|
||||
.preview-badges {
|
||||
// These will float over the image in the bottom right corner
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: var(--gap-sm) var(--gap-xs);
|
||||
|
||||
.badge {
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: var(--gap-xs) var(--gap-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
&.show-bg {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
-khtml-user-drag: none;
|
||||
-moz-user-drag: none;
|
||||
-o-user-drag: none;
|
||||
user-drag: none;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
svg {
|
||||
width: 2rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.enlarge {
|
||||
width: auto;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
svg {
|
||||
min-height: 1rem;
|
||||
min-width: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invalid {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
margin: var(--spacing-card-md) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.country-multiselect,
|
||||
.iconified-input {
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.rewards-checkbox {
|
||||
a {
|
||||
margin-left: 0.5ch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
apps/frontend/src/pages/flags.vue
Normal file
67
apps/frontend/src/pages/flags.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type FeatureFlag,
|
||||
DEFAULT_FEATURE_FLAGS,
|
||||
saveFeatureFlags,
|
||||
} from '~/composables/featureFlags.ts'
|
||||
|
||||
const flags = shallowReactive(useFeatureFlags().value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1>Feature flags</h1>
|
||||
<div class="flags">
|
||||
<div
|
||||
v-for="flag in Object.keys(flags) as FeatureFlag[]"
|
||||
:key="`flag-${flag}`"
|
||||
class="adjacent-input small card"
|
||||
>
|
||||
<label :for="`toggle-${flag}`">
|
||||
<span class="label__title">
|
||||
{{ flag.replaceAll('_', ' ') }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
<p>
|
||||
Default:
|
||||
<span
|
||||
:style="`color:var(--color-${
|
||||
DEFAULT_FEATURE_FLAGS[flag] === false ? 'red' : 'green'
|
||||
})`"
|
||||
>{{ DEFAULT_FEATURE_FLAGS[flag] }}</span
|
||||
>
|
||||
</p>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`toggle-${flag}`"
|
||||
v-model="flags[flag]"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="() => saveFeatureFlags()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
width: calc(100% - 2 * var(--spacing-card-md));
|
||||
max-width: 800px;
|
||||
margin-inline: auto;
|
||||
box-sizing: border-box;
|
||||
margin-block: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.flags {
|
||||
}
|
||||
|
||||
.label__title {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.label__description p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
75
apps/frontend/src/pages/frog.vue
Normal file
75
apps/frontend/src/pages/frog.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
const messages = defineMessages({
|
||||
frogTitle: {
|
||||
id: 'frog.title',
|
||||
defaultMessage: 'Frog',
|
||||
},
|
||||
frogDescription: {
|
||||
id: 'frog',
|
||||
defaultMessage: "You've been frogged! 🐸",
|
||||
},
|
||||
frogAltText: {
|
||||
id: 'frog.altText',
|
||||
defaultMessage: 'A photorealistic painting of a frog labyrinth',
|
||||
},
|
||||
frogSinceOpened: {
|
||||
id: 'frog.sinceOpened',
|
||||
defaultMessage: 'This page was opened {ago}',
|
||||
},
|
||||
frogFroggedPeople: {
|
||||
id: 'frog.froggedPeople',
|
||||
defaultMessage:
|
||||
'{count, plural, one {{count} more person} other {{count} more people}} were also frogged!',
|
||||
},
|
||||
})
|
||||
|
||||
const formatCompactNumber = useCompactNumber()
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const pageOpen = useState('frogPageOpen', () => Date.now())
|
||||
const peopleFrogged = useState('frogPeopleFrogged', () => Math.round(Math.random() * 100_000_000))
|
||||
const peopleFroggedCount = computed(() => formatCompactNumber(peopleFrogged.value))
|
||||
|
||||
let interval: ReturnType<typeof setTimeout>
|
||||
|
||||
const formattedOpenedCounter = ref(formatRelativeTime(Date.now()))
|
||||
|
||||
onMounted(() => {
|
||||
interval = setInterval(() => {
|
||||
formattedOpenedCounter.value = formatRelativeTime(pageOpen.value)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h1>{{ formatMessage(messages.frogTitle) }}</h1>
|
||||
<p>{{ formatMessage(messages.frogDescription) }}</p>
|
||||
<img src="https://cdn.modrinth.com/frog.png" :alt="formatMessage(messages.frogAltText)" />
|
||||
<p>{{ formatMessage(messages.frogSinceOpened, { ago: formattedOpenedCounter }) }}</p>
|
||||
<p>{{ formatMessage(messages.frogFroggedPeople, { count: peopleFroggedCount }) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
width: calc(100% - 2 * var(--spacing-card-md));
|
||||
max-width: 1280px;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
margin-block: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
img {
|
||||
margin-block: 0 1.5rem;
|
||||
width: 60%;
|
||||
max-width: 40rem;
|
||||
}
|
||||
</style>
|
||||
1345
apps/frontend/src/pages/index.vue
Normal file
1345
apps/frontend/src/pages/index.vue
Normal file
File diff suppressed because one or more lines are too long
74
apps/frontend/src/pages/legal.vue
Normal file
74
apps/frontend/src/pages/legal.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Legal</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/legal/terms" label="Terms of Use">
|
||||
<HeartHandshakeIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/legal/rules" label="Content Rules">
|
||||
<ScaleIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/legal/copyright" label="Copyright Policy">
|
||||
<CopyrightIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/legal/security" label="Security Notice">
|
||||
<ShieldIcon />
|
||||
</NavStackItem>
|
||||
|
||||
<h3>Privacy</h3>
|
||||
<NavStackItem link="/legal/privacy" label="Privacy Policy">
|
||||
<LockIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/legal/ccpa" label="California Privacy Notice">
|
||||
<InfoIcon />
|
||||
</NavStackItem>
|
||||
|
||||
<h3>Rewards Program</h3>
|
||||
<NavStackItem link="/legal/cmp" label="Rewards Program Terms">
|
||||
<CurrencyIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/legal/cmp-info" label="Rewards Program Info">
|
||||
<InfoIcon />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<NuxtPage class="universal-card" :route="route" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
InfoIcon,
|
||||
HeartHandshakeIcon,
|
||||
LockIcon,
|
||||
ScaleIcon,
|
||||
ShieldIcon,
|
||||
CurrencyIcon,
|
||||
CopyrightIcon,
|
||||
} from '@modrinth/assets'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
|
||||
const route = useNativeRoute()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.normal-page__content :deep(a) {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-link-active);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
464
apps/frontend/src/pages/legal/ccpa.vue
Normal file
464
apps/frontend/src/pages/legal/ccpa.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Privacy Notice for California Residents</h1>
|
||||
<p><strong>Effective Date: </strong><em>August 5th, 2023</em></p>
|
||||
<p><strong>Last reviewed on: </strong><em>August 5th, 2023</em></p>
|
||||
<p>
|
||||
This Privacy Notice for California Residents supplements the information contained in the
|
||||
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link> of Rinth, Inc. (the "Company," "we,"
|
||||
"us" or "our") and applies solely to all visitors, users, and others who reside in the State
|
||||
of California ("consumers" or "you"). We adopt this notice to comply with the California
|
||||
Consumer Privacy Act of 2018 (CCPA), as it may be amended, modified or supplemented from time
|
||||
to time, and any terms defined in the CCPA have the same meaning when used in this notice.
|
||||
</p>
|
||||
|
||||
<h2>Information We Collect</h2>
|
||||
<p>
|
||||
Our Service collects information that identifies, relates to, describes, references, is
|
||||
capable of being associated with, or could reasonably be linked, directly or indirectly, with
|
||||
a particular consumer or device (<strong>"personal information"</strong>). In particular, our
|
||||
Service has collected the following categories of personal information from its consumers
|
||||
within the last twelve (12) months:
|
||||
</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Examples</th>
|
||||
<th>Collected</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>A. Identifiers.</td>
|
||||
<td>
|
||||
A real name, alias, postal address, unique personal identifier, online identifier,
|
||||
Internet Protocol address, email address, account name, Social Security number, driver's
|
||||
license number, passport number, or other similar identifiers.
|
||||
</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
B. Personal information categories listed in the California Customer Records statute (Cal.
|
||||
Civ. Code § 1798.80(e)).
|
||||
</td>
|
||||
<td>
|
||||
A name, signature, Social Security number, physical characteristics or description,
|
||||
address, telephone number, passport number, driver's license or state identification card
|
||||
number, insurance policy number, education, employment, employment history, bank account
|
||||
number, credit card number, debit card number, or any other financial information, medical
|
||||
information, or health insurance information. <br /><br />
|
||||
Some personal information included in this category may overlap with other categories.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>C. Protected classification characteristics.</td>
|
||||
<td>
|
||||
Age (40 years or older), race, color, ancestry, national origin, citizenship, religion or
|
||||
creed, marital status, medical condition, physical or mental disability, sex (including
|
||||
gender, gender identity, gender expression, pregnancy or childbirth and related medical
|
||||
conditions), sexual orientation, veteran or military status, genetic information
|
||||
(including familial genetic information).
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>D. Commercial information.</td>
|
||||
<td>
|
||||
Records of personal property, products or services purchased, obtained, or considered, or
|
||||
other purchasing or consuming histories or tendencies.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>E. Biometric information.</td>
|
||||
<td>
|
||||
Genetic, physiological, behavioral, and biological characteristics, or activity patterns
|
||||
used to extract a template or other identifier or identifying information, such as,
|
||||
fingerprints, faceprints, and voiceprints, iris or retina scans, keystroke, gait, or other
|
||||
physical patterns, and sleep, health, or exercise data.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>F. Internet or other similar network activity.</td>
|
||||
<td>
|
||||
Browsing history, search history, information on a consumer's interaction with a website,
|
||||
application, or advertisement.
|
||||
</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>G. Geolocation data.</td>
|
||||
<td>Physical location or movements.</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>H. Sensory data.</td>
|
||||
<td>Audio, electronic, visual, thermal, olfactory, or similar information.</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>I. Professional or employment-related information.</td>
|
||||
<td>Current or past job history or performance evaluations.</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
J. Non-public education information (per the Family Educational Rights and Privacy Act (20
|
||||
U.S.C. Section 1232g, 34 C.F.R. Part 99)).
|
||||
</td>
|
||||
<td>
|
||||
Education records directly related to a student maintained by an educational institution
|
||||
or party acting on its behalf, such as grades, transcripts, class lists, student
|
||||
schedules, student identification codes, student financial information, or student
|
||||
disciplinary records.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>K. Inferences drawn from other personal information.</td>
|
||||
<td>
|
||||
Profile reflecting a person's preferences, characteristics, psychological trends,
|
||||
predispositions, behavior, attitudes, intelligence, abilities, and aptitudes.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>Personal information does not include:</p>
|
||||
<ul>
|
||||
<li>Publicly available information from government records.</li>
|
||||
<li>Deidentified or aggregated consumer information.</li>
|
||||
<li>Information excluded from the CCPA's scope, like:</li>
|
||||
<ul>
|
||||
<li>
|
||||
health or medical information covered by the Health Insurance Portability and
|
||||
Accountability Act of 1996 (HIPAA) and the California Confidentiality of Medical
|
||||
Information Act (CMIA) or clinical trial data;
|
||||
</li>
|
||||
<li>
|
||||
personal information covered by certain sector-specific privacy laws, including the Fair
|
||||
Credit Reporting Act (FRCA), the Gramm-Leach-Bliley Act (GLBA) or California Financial
|
||||
Information Privacy Act (FIPA), and the Driver's Privacy Protection Act of 1994.
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
<p>
|
||||
We obtain the categories of personal information listed above from the following categories of
|
||||
sources:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Directly from you. For example, from forms you complete or products and services you
|
||||
purchase.
|
||||
</li>
|
||||
<li>Indirectly from you. For example, from observing your actions on our Service.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Use of Personal Information</h2>
|
||||
<p>
|
||||
We may use or disclose the personal information we collect for one or more of the following
|
||||
business purposes:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
To fulfill or meet the reason you provided the information. For example, if you share your
|
||||
name and contact information to request a price quote or ask a question about our products
|
||||
or services, we will use that personal information to respond to your inquiry. If you
|
||||
provide your personal information to purchase a product or service, we will use that
|
||||
information to process your payment and facilitate delivery. We may also save your
|
||||
information to facilitate new product orders or process returns.
|
||||
</li>
|
||||
<li>To provide, support, personalize, and develop our Service, products, and services.</li>
|
||||
<li>To create, maintain, customize, and secure your account with us.</li>
|
||||
<li>
|
||||
To process your requests, purchases, transactions, and payments and prevent transactional
|
||||
fraud.
|
||||
</li>
|
||||
<li>
|
||||
To provide you with support and to respond to your inquiries, including to investigate and
|
||||
address your concerns and monitor and improve our responses.
|
||||
</li>
|
||||
<li>
|
||||
To personalize your Service experience and to deliver content and product and service
|
||||
offerings relevant to your interests, including targeted offers and ads through our Service,
|
||||
third-party sites, and via email or text message (with your consent, where required by law).
|
||||
</li>
|
||||
<li>
|
||||
To help maintain the safety, security, and integrity of our Service, products and services,
|
||||
databases and other technology assets, and business.
|
||||
</li>
|
||||
<li>
|
||||
For testing, research, analysis, and product development, including to develop and improve
|
||||
our Service, products, and services.
|
||||
</li>
|
||||
<li>
|
||||
To respond to law enforcement requests and as required by applicable law, court order, or
|
||||
governmental regulations.
|
||||
</li>
|
||||
<li>
|
||||
As described to you when collecting your personal information or as otherwise set forth in
|
||||
the CCPA.
|
||||
</li>
|
||||
<li>
|
||||
To evaluate or conduct a merger, divestiture, restructuring, reorganization, dissolution, or
|
||||
other sale or transfer of some or all of the Company's assets, whether as a going concern or
|
||||
as part of bankruptcy, liquidation, or similar proceeding, in which personal information
|
||||
held by the Company about our Service users is among the assets transferred.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
We will not collect additional categories of personal information or use the personal
|
||||
information we collected for materially different, unrelated, or incompatible purposes without
|
||||
providing you notice.
|
||||
</p>
|
||||
|
||||
<h2>Sharing Personal Information</h2>
|
||||
<p>
|
||||
We may disclose your personal information to a third party for a business purpose. When we
|
||||
disclose personal information for a business purpose, we enter a contract that describes the
|
||||
purpose and requires the recipient to both keep that personal information confidential and not
|
||||
use it for any purpose except performing the contract. The CCPA prohibits third parties who
|
||||
purchase the personal information we hold from reselling it unless you have received explicit
|
||||
notice and an opportunity to opt-out of further sales.
|
||||
</p>
|
||||
|
||||
<h3>Disclosures of Personal Information for a Business Purpose</h3>
|
||||
<p>
|
||||
In the preceding twelve (12) months, Company has disclosed the following categories of
|
||||
personal information for a business purpose:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Category A: Identifiers.</li>
|
||||
<li>Category F: Internet or other similar network activity.</li>
|
||||
<li>Category G: Geolocation data.</li>
|
||||
</ul>
|
||||
<p>
|
||||
We disclose your personal information for a business purpose to the following categories of
|
||||
third parties:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Service providers.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Sales of Personal Information</h3>
|
||||
<p>In the preceding twelve (12) months, Company has not sold personal information.</p>
|
||||
|
||||
<h2>Your Rights and Choices</h2>
|
||||
<p>
|
||||
The CCPA provides consumers (California residents) with specific rights regarding their
|
||||
personal information. This section describes your CCPA rights and explains how to exercise
|
||||
those rights.
|
||||
</p>
|
||||
|
||||
<h3>Access to Specific Information and Data Portability Rights</h3>
|
||||
<p>
|
||||
You have the right to request that we disclose certain information to you about our collection
|
||||
and use of your personal information over the past 12 months. Once we receive and confirm your
|
||||
verifiable consumer request (see
|
||||
<i>Exercising Access, Data Portability, and Deletion Rights</i>), we will disclose to you:
|
||||
</p>
|
||||
<ul>
|
||||
<li>The categories of personal information we collected about you.</li>
|
||||
<li>The categories of sources for the personal information we collected about you.</li>
|
||||
<li>
|
||||
Our business or commercial purpose for collecting or selling that personal information.
|
||||
</li>
|
||||
<li>The categories of third parties with whom we share that personal information.</li>
|
||||
<li>
|
||||
The specific pieces of personal information we collected about you (also called a data
|
||||
portability request).
|
||||
</li>
|
||||
<li>
|
||||
If we sold or disclosed your personal information for a business purpose, two separate lists
|
||||
disclosing:
|
||||
</li>
|
||||
<ul>
|
||||
<li>
|
||||
sales, identifying the personal information categories that each category of recipient
|
||||
purchased; and
|
||||
</li>
|
||||
<li>
|
||||
disclosures for a business purpose, identifying the personal information categories that
|
||||
each category of recipient obtained.
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<h3>Deletion Request Rights</h3>
|
||||
<p>
|
||||
You have the right to request that we delete any of your personal information that we
|
||||
collected from you and retained, subject to certain exceptions. Once we receive and confirm
|
||||
your verifiable consumer request (see
|
||||
<i>Exercising Access, Data Portability, and Deletion Rights</i>), we will delete (and direct
|
||||
our service providers to delete) your personal information from our records, unless an
|
||||
exception applies.
|
||||
</p>
|
||||
<p>
|
||||
We may deny your deletion request if retaining the information is necessary for us or our
|
||||
service provider(s) to:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
Complete the transaction for which we collected the personal information, provide a good or
|
||||
service that you requested, take actions reasonably anticipated within the context of our
|
||||
ongoing business relationship with you, or otherwise perform our contract with you.
|
||||
</li>
|
||||
<li>
|
||||
Detect security incidents, protect against malicious, deceptive, fraudulent, or illegal
|
||||
activity, or prosecute those responsible for such activities.
|
||||
</li>
|
||||
<li>
|
||||
Debug products to identify and repair errors that impair existing intended functionality.
|
||||
</li>
|
||||
<li>
|
||||
Exercise free speech, ensure the right of another consumer to exercise their free speech
|
||||
rights, or exercise another right provided for by law.
|
||||
</li>
|
||||
<li>
|
||||
Comply with the California Electronic Communications Privacy Act (Cal. Penal Code § 1546
|
||||
<i>et. seq.</i>).
|
||||
</li>
|
||||
<li>
|
||||
Engage in public or peer-reviewed scientific, historical, or statistical research in the
|
||||
public interest that adheres to all other applicable ethics and privacy laws, when the
|
||||
information's deletion may likely render impossible or seriously impair the research's
|
||||
achievement, if you previously provided informed consent.
|
||||
</li>
|
||||
<li>
|
||||
Enable solely internal uses that are reasonably aligned with consumer expectations based on
|
||||
your relationship with us.
|
||||
</li>
|
||||
<li>Comply with a legal obligation.</li>
|
||||
<li>
|
||||
Make other internal and lawful uses of that information that are compatible with the context
|
||||
in which you provided it.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3>Exercising Access, Data Portability, and Deletion Rights</h3>
|
||||
<p>
|
||||
To exercise the access, data portability, and deletion rights described above, please submit a
|
||||
verifiable consumer request to us by emailing us at
|
||||
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
|
||||
</p>
|
||||
<p>
|
||||
Only you, or a person registered with the California Secretary of State that you authorize to
|
||||
act on your behalf, may make a verifiable consumer request related to your personal
|
||||
information. You may also make a verifiable consumer request on behalf of your minor child.
|
||||
</p>
|
||||
<p>
|
||||
You may only make a verifiable consumer request for access or data portability twice within a
|
||||
12-month period. The verifiable consumer request must:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Provide sufficient information that allows us to reasonably verify you are the person about
|
||||
whom we collected personal information or an authorized representative.
|
||||
</li>
|
||||
<li>
|
||||
Describe your request with sufficient detail that allows us to properly understand,
|
||||
evaluate, and respond to it.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
We cannot respond to your request or provide you with personal information if we cannot verify
|
||||
your identity or authority to make the request and confirm the personal information relates to
|
||||
you.
|
||||
</p>
|
||||
<p>
|
||||
Making a verifiable consumer request does not require you to create an account with us.
|
||||
However, we do consider requests made through your password protected account sufficiently
|
||||
verified when the request relates to personal information associated with that specific
|
||||
account.
|
||||
</p>
|
||||
<p>
|
||||
We will only use personal information provided in a verifiable consumer request to verify the
|
||||
requestor's identity or authority to make the request.
|
||||
</p>
|
||||
<p>
|
||||
For instructions on exercising sale opt-out rights, see
|
||||
<i>Personal Information Sales Opt-Out and Opt-In Rights.</i>
|
||||
</p>
|
||||
|
||||
<h3>Response Timing and Format</h3>
|
||||
<p>
|
||||
We endeavor to respond to a verifiable consumer request within forty-five (45) days of its
|
||||
receipt. If we require more time (up to 90 days), we will inform you of the reason and
|
||||
extension period in writing.
|
||||
</p>
|
||||
<p>
|
||||
If you have an account with us, we will deliver our written response to that account. If you
|
||||
do not have an account with us, we will deliver our written response by mail or
|
||||
electronically, at your option.
|
||||
</p>
|
||||
<p>
|
||||
Any disclosures we provide will only cover the 12-month period preceding the verifiable
|
||||
consumer request's receipt. The response we provide will also explain the reasons we cannot
|
||||
comply with a request, if applicable. For data portability requests, we will select a format
|
||||
to provide your personal information that is readily useable and should allow you to transmit
|
||||
the information from one entity to another entity without hindrance.
|
||||
</p>
|
||||
<p>
|
||||
We do not charge a fee to process or respond to your verifiable consumer request unless it is
|
||||
excessive, repetitive, or manifestly unfounded. If we determine that the request warrants a
|
||||
fee, we will tell you why we made that decision and provide you with a cost estimate before
|
||||
completing your request.
|
||||
</p>
|
||||
|
||||
<h2>Non-Discrimination</h2>
|
||||
<p>
|
||||
We will not discriminate against you for exercising any of your CCPA rights. Unless permitted
|
||||
by the CCPA, we will not:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Deny you goods or services.</li>
|
||||
<li>
|
||||
Charge you different prices or rates for goods or services, including through granting
|
||||
discounts or other benefits, or imposing penalties.
|
||||
</li>
|
||||
<li>Provide you a different level or quality of goods or services.</li>
|
||||
<li>
|
||||
Suggest that you may receive a different price or rate for goods or services or a different
|
||||
level or quality of goods or services.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
However, we may offer you certain financial incentives permitted by the CCPA that
|
||||
<strong>can result</strong>
|
||||
in different prices, rates, or quality levels. Any CCPA-permitted financial incentive we offer
|
||||
will reasonably relate to your personal information's value and contain written terms that
|
||||
describe the program's material aspects. Participation in a financial incentive program
|
||||
requires your prior opt in consent, which you may revoke at any time.
|
||||
</p>
|
||||
<h2>Changes to Our Privacy Notice</h2>
|
||||
<p>
|
||||
We reserve the right to amend this privacy notice at our discretion and at any time. When we
|
||||
make changes to this privacy notice, we will post the updated notice on the Service and update
|
||||
the notice's effective date.
|
||||
<strong
|
||||
>Your continued use of our Service following the posting of changes constitutes your
|
||||
acceptance of such changes.
|
||||
</strong>
|
||||
</p>
|
||||
<h2>Contact Information</h2>
|
||||
<p>
|
||||
If you have any questions or comments about this notice, the ways in which we collect and use
|
||||
your information described below and in the
|
||||
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>, your choices and rights regarding
|
||||
such use, or wish to exercise your rights under California law, please do not hesitate to
|
||||
contact us at <a href="mailto:support@modrinth.com">support@modrinth.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'The California Privacy Notice of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'California Privacy Notice - Modrinth',
|
||||
description,
|
||||
ogTitle: 'California Privacy Notice',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
101
apps/frontend/src/pages/legal/cmp-info.vue
Normal file
101
apps/frontend/src/pages/legal/cmp-info.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Rewards Program Information</h1>
|
||||
<p><em>Last modified: May 13, 2024</em></p>
|
||||
<p>
|
||||
This page was created for transparency for how the rewards program works on Modrinth. Feel
|
||||
free to join our Discord or email
|
||||
<a href="mailto:support@modrinth.com">support@modrinth.com</a> if you have any questions!
|
||||
</p>
|
||||
<p>
|
||||
This document is provided for informational purposes only and does not constitute a legal
|
||||
agreement. Modrinth makes no representations or warranties as to the accuracy, completeness,
|
||||
or reliability of the information contained herein.
|
||||
</p>
|
||||
<h2>Rewards Distribution</h2>
|
||||
<p>
|
||||
We collect ad revenue on our website and app through our ad network
|
||||
<a href="https://adrinth.com">Adrinth</a>. We then distribute this ad revenue to creators.
|
||||
</p>
|
||||
<p>
|
||||
The advertising revenue of the entire website and app is split 90% to creators and 10% to
|
||||
Modrinth.
|
||||
</p>
|
||||
<p>
|
||||
The creator allotment to the pool is decided by how many page views and in-app downloads your
|
||||
project receives (user profiles are not used in this calculation). Each page view and in-app
|
||||
download counts as a "point". Then, the money is distributed based on each author's point
|
||||
earnings daily.
|
||||
</p>
|
||||
<p>For example, consider this test scenario (all numbers are fake):</p>
|
||||
<ul>
|
||||
<li>The site earns $100 on a day.</li>
|
||||
<li>User A has the projects: NoobCraft and Minesweeper</li>
|
||||
<li>NoobCraft receives 10 page views and 30 in-app downloads (40 points)</li>
|
||||
<li>Minesweeper receives 100 page views and 10 in-app downloads (110 points)</li>
|
||||
<li>
|
||||
User B and C both co-own these projects: Bloxcraft and Craftnite. They split their payouts
|
||||
40/60.
|
||||
</li>
|
||||
<li>Bloxcraft receives 50 page views and 20 in-app downloads (70 points)</li>
|
||||
<li>Craftnite receives 10 page views and 0 in-app downloads (10 points)</li>
|
||||
</ul>
|
||||
<p>In this scenario, the earnings for each creator and Modrinth would be as follows:</p>
|
||||
<ul>
|
||||
<li>Modrinth: $10 (10% of $100, the site's earnings for the day)</li>
|
||||
<li>User A: $58.69 ($90 * (10 + 30 + 100 + 10)/230)</li>
|
||||
<li>User B: $12.52 (0.4 * $90 * (50 + 20 + 10 + 0)/230)</li>
|
||||
<li>User C: $18.78 (0.6 * $90 * (50 + 20 + 10 + 0)/230)</li>
|
||||
<li>Note: 230 is the sum of all page views and in-app downloads from above</li>
|
||||
</ul>
|
||||
<p>
|
||||
Page views are counted when a legitimate browser views a project page. In-app downloads when a
|
||||
user logged into the launcher downloads a project. Project downloads alongside modpack
|
||||
downloads are counted equally. In each category, Modrinth actively removes botted downloads
|
||||
and page views at our own discretion. If users are caught botting, they will be permanently
|
||||
banned from using Modrinth's services.
|
||||
</p>
|
||||
<p>
|
||||
You can view your page views and project downloads in your
|
||||
<a href="https://modrinth.com/dashboard/analytics">analytics dashboard</a>.
|
||||
</p>
|
||||
<h2>Frequently Asked Questions</h2>
|
||||
<p>
|
||||
This section covers some common concerns people have about our monetization program. If you
|
||||
have more, feel free to join our Discord or contact support.
|
||||
</p>
|
||||
<h3>Do you have to enroll in the monetization program to get money?</h3>
|
||||
<p>
|
||||
No. All creators who upload to Modrinth automatically will receive funds as according to the
|
||||
above algorithm. However, if you would like to withdraw money from your account, you must
|
||||
enroll by adding your payment information.
|
||||
</p>
|
||||
<h3>What methods can I use withdraw money from my account? Are there any fees?</h3>
|
||||
<p>
|
||||
Right now, you can use PayPal or Venmo to withdraw money from your Modrinth account. We are
|
||||
working on more methods to withdraw money from your account. There are fees to withdraw money
|
||||
from your Modrinth account—see the revenue page in your dashboard for more information.
|
||||
</p>
|
||||
<h3>Modrinth used to give 100% of project page revenue to creators. What changed?</h3>
|
||||
<p>
|
||||
While this is true, our new system (as of 08/05/23) gives more of the site's revenue to
|
||||
creators, so creators will earn more. In the old system, we would earn revenue through
|
||||
advertisements in search and user profile pages. This amounted on average each month to about
|
||||
15-20% of the site's total advertising revenue (so a 80-85% split to creators). The new system
|
||||
gives creators more revenue and a more favorable split towards creators (90%).
|
||||
</p>
|
||||
<h3></h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Rewards Program Information - Modrinth',
|
||||
description,
|
||||
ogTitle: 'Rewards Program Information',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
86
apps/frontend/src/pages/legal/cmp.vue
Normal file
86
apps/frontend/src/pages/legal/cmp.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Rewards Program Terms</h1>
|
||||
<p>
|
||||
These REWARDS PROGRAM TERMS ("Terms") constitute a legally binding agreement between you (or
|
||||
the entity you represent) ("you") and Rinth, Inc. ("Rinth") concerning your participation in
|
||||
the Modrinth Rewards Program (the "Rewards Program").
|
||||
</p>
|
||||
<p>
|
||||
The Rewards Program provides developers and content creators an opportunity to monetize the
|
||||
projects ("Projects") that they upload to the Modrinth website.
|
||||
</p>
|
||||
<p>
|
||||
These Terms are in addition to and do not in any manner limit the applicability of the
|
||||
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link>, the
|
||||
<nuxt-link to="/legal/rules">Content Rules</nuxt-link>, or the
|
||||
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>.
|
||||
</p>
|
||||
|
||||
<h2>Economics</h2>
|
||||
<p>
|
||||
Rinth shall pay to you the percentage set forth
|
||||
<nuxt-link to="/legal/cmp-info">here</nuxt-link> of net revenue collected by Rinth
|
||||
attributable to ad impressions displayed on modrinth.com and the Modrinth App excluding
|
||||
transaction fees ("Revenue Share"). Rinth shall make Revenue Share payments to you when you
|
||||
withdraw funds from Rinth's dashboard. Rinth shall include with each such payment either
|
||||
access to a dashboard or other reasonable reporting detailing the calculation thereof.
|
||||
</p>
|
||||
|
||||
<h2>Relationship</h2>
|
||||
<p>
|
||||
Your relationship with Rinth relating to the Rewards Program is that of an independent
|
||||
contractor. In participating in the Rewards Program, you will not be deemed an employee of
|
||||
Rinth, you are not eligible for any Rinth employee benefits, and you are solely responsible
|
||||
for determining and paying any taxes applicable to amounts paid to you by Rinth hereunder. You
|
||||
agree to indemnify and hold harmless Rinth from and against any claim that Rinth is
|
||||
responsible for payment of any such taxes.
|
||||
</p>
|
||||
|
||||
<h2>Disclaimer Regarding Rewards Program</h2>
|
||||
<p>
|
||||
YOUR PARTICIPATION IN THE REWARDS PROGRAM IS AT YOUR OWN RISK. THE REWARDS PROGRAM IS PROVIDED
|
||||
ON AN "AS IS" AND "AS AVAILABLE" BASIS. TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW,
|
||||
RINTH EXPRESSLY DISCLAIMS ALL WARRANTIES OF ANY KIND, WHETHER EXPRESSED OR IMPLIED, INCLUDING,
|
||||
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NON-INFRINGEMENT. RINTH MAKES NO WARRANTY THAT (I) THE REWARDS PROGRAM WILL MEET
|
||||
YOUR REQUIREMENTS, (II) THE REWARDS PROGRAM WILL GENERATE ANY MINIMUM REVENUE, AND/OR (III)
|
||||
THE REWARDS PROGRAM WILL BE UNINTERRUPTED, TIMELY, SECURE, OR ERROR-FREE.
|
||||
</p>
|
||||
|
||||
<h2>Limitation of Liability</h2>
|
||||
<p>
|
||||
YOU ACKNOWLEDGE AND AGREE THAT, TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, (A) RINTH
|
||||
WILL NOT BE LIABLE TO YOU FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY
|
||||
DAMAGES, WHICH YOU MAY INCUR, EVEN IF RINTH HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES, ARISING OUT OF OR IN CONNECTION WITH THE REWARDS PROGRAM OR THESE TERMS AND (B) RINTH
|
||||
WILL NOT BE LIABLE TO YOU FOR MORE THAN THE AMOUNT YOU RECEIVED IN CONNECTION WITH THE REWARDS
|
||||
PROGRAM IN THE SIX MONTHS PRIOR TO THE TIME YOUR CAUSE OF ACTION AROSE.
|
||||
</p>
|
||||
|
||||
<h2>Governing Law</h2>
|
||||
<p>
|
||||
These Terms shall be governed by and construed in accordance with the internal laws of the
|
||||
State of Delaware.
|
||||
</p>
|
||||
|
||||
<h2>Termination</h2>
|
||||
<p>
|
||||
Rinth reserves the right, in our sole discretion and without notice or liability, to terminate
|
||||
these Terms or modify or cease to offer the Rewards Program at any time, to any person, for
|
||||
any reason or no reason.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'The Rewards Program Terms of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Rewards Program Terms - Modrinth',
|
||||
description,
|
||||
ogTitle: 'Rewards Program Terms',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
114
apps/frontend/src/pages/legal/copyright.vue
Normal file
114
apps/frontend/src/pages/legal/copyright.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Copyright Policy</h1>
|
||||
<h2>Reporting Claims of Copyright Infringement</h2>
|
||||
<p>
|
||||
We take claims of copyright infringement seriously. We will respond to notices of alleged
|
||||
copyright infringement that comply with applicable law. If you believe any materials
|
||||
accessible on or from this site (the <strong>"Website"</strong>) infringe your copyright, you
|
||||
may request removal of those materials (or access to them) from the Website by submitting
|
||||
written notification to our copyright agent designated below. In accordance with the Online
|
||||
Copyright Infringement Liability Limitation Act of the Digital Millennium Copyright Act (17
|
||||
U.S.C. § 512) (<strong>"DMCA"</strong>), the written notice (the
|
||||
<strong>"DMCA Notice"</strong>) must include substantially the following:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your physical or electronic signature.</li>
|
||||
<li>
|
||||
Identification of the copyrighted work you believe to have been infringed or, if the claim
|
||||
involves multiple works on the Website, a representative list of such works.
|
||||
</li>
|
||||
<li>
|
||||
Identification of the material you believe to be infringing in a sufficiently precise manner
|
||||
to allow us to locate that material.
|
||||
</li>
|
||||
<li>
|
||||
Adequate information by which we can contact you (including your name, postal address,
|
||||
telephone number, and, if available, email address).
|
||||
</li>
|
||||
<li>
|
||||
A statement that you have a good faith belief that use of the copyrighted material is not
|
||||
authorized by the copyright owner, its agent, or the law.
|
||||
</li>
|
||||
<li>A statement that the information in the written notice is accurate.</li>
|
||||
<li>
|
||||
A statement, under penalty of perjury, that you are authorized to act on behalf of the
|
||||
copyright owner.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Our designated copyright agent to receive DMCA Notices is:</p>
|
||||
<p>
|
||||
 Jai Agrawal<br />
|
||||
 Rinth, Inc.<br />
|
||||
 410 N Scottsdale Road, Suite 1000, Tempe, Arizona, 85281<br />
|
||||
 <a href="mailto:support@modrinth.com">support@modrinth.com</a><br />
|
||||
</p>
|
||||
<p>
|
||||
If you fail to comply with all of the requirements of Section 512(c)(3) of the DMCA, your DMCA
|
||||
Notice may not be effective.
|
||||
</p>
|
||||
<p>
|
||||
Please be aware that if you knowingly materially misrepresent that material or activity on the
|
||||
Website is infringing your copyright, you may be held liable for damages (including costs and
|
||||
attorneys' fees) under Section 512(f) of the DMCA.
|
||||
</p>
|
||||
<h2>Counter Notification Procedures</h2>
|
||||
<p>
|
||||
If you believe that material you posted on the Website was removed or access to it was
|
||||
disabled by mistake or misidentification, you may file a counter notification with us (a
|
||||
<strong>"Counter Notice"</strong>) by submitting written notification to our copyright agent
|
||||
designated above. Pursuant to the DMCA, the Counter Notice must include substantially the
|
||||
following:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your physical or electronic signature.</li>
|
||||
<li>
|
||||
An identification of the material that has been removed or to which access has been disabled
|
||||
and the location at which the material appeared before it was removed or access disabled.
|
||||
</li>
|
||||
<li>
|
||||
Adequate information by which we can contact you (including your name, postal address,
|
||||
telephone number, and, if available, email address).
|
||||
</li>
|
||||
<li>
|
||||
A statement under penalty of perjury by you that you have a good faith belief that the
|
||||
material identified above was removed or disabled as a result of a mistake or
|
||||
misidentification of the material to be removed or disabled.
|
||||
</li>
|
||||
<li>
|
||||
A statement that you will consent to the jurisdiction of the Federal District Court for the
|
||||
judicial district in which your address is located (or if you reside outside the United
|
||||
States for any judicial district in which the Website may be found) and that you will accept
|
||||
service from the person (or an agent of that person) who provided the Website with the
|
||||
complaint at issue.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
The DMCA allows us to restore the removed content if the party filing the original DMCA Notice
|
||||
does not file a court action against you within ten business days of receiving the copy of
|
||||
your Counter Notice.
|
||||
</p>
|
||||
<p>
|
||||
Please be aware that if you knowingly materially misrepresent that material or activity on the
|
||||
Website was removed or disabled by mistake or misidentification, you may be held liable for
|
||||
damages (including costs and attorneys' fees) under Section 512(f) of the DMCA.
|
||||
</p>
|
||||
<h2>Repeat Infringers</h2>
|
||||
<p>
|
||||
It is our policy in appropriate circumstances to disable and/or terminate the accounts of
|
||||
users who are repeat infringers.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'The Copyright Policy of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Copyright Policy - Modrinth',
|
||||
description,
|
||||
ogTitle: 'Copyright Policy',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
321
apps/frontend/src/pages/legal/privacy.vue
Normal file
321
apps/frontend/src/pages/legal/privacy.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p><em>Last modified: November 17, 2023</em></p>
|
||||
|
||||
<h2>Introduction</h2>
|
||||
<p>
|
||||
<a href="https://modrinth.com">Modrinth</a> is part of Rinth, Inc. ("Company", "us", "we",
|
||||
"our"). This privacy policy explains how we collect data, process it, and your rights relative
|
||||
to your data.
|
||||
</p>
|
||||
<p>
|
||||
This policy describes the types of information we may collect from you or that you may provide
|
||||
when you use www.modrinth.com, api.modrinth.com, or the Modrinth App ("Service" or "Website"),
|
||||
and our practices for collecting, using, maintaining, protecting, and disclosing that
|
||||
information.
|
||||
</p>
|
||||
<p>This policy applies to information we collect:</p>
|
||||
<ul>
|
||||
<li>On this Website.</li>
|
||||
<li>In email, text, and other electronic messages between you and this Website.</li>
|
||||
<li>
|
||||
Through mobile and desktop applications you download from this Website, which provide
|
||||
dedicated non-browser-based interaction between you and this Website.
|
||||
</li>
|
||||
<li>
|
||||
When you interact with our advertising and applications on third-party websites and
|
||||
services, if those applications or advertising include links to this policy.
|
||||
</li>
|
||||
</ul>
|
||||
<p>It does not apply to information collected by:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Us offline or through any other means, including on any other website operated by Rinth,
|
||||
Inc. or any third party (including our affiliates and subsidiaries); or
|
||||
</li>
|
||||
<li>
|
||||
Any third party (including our affiliates and subsidiaries), including through any
|
||||
application or content (including advertising) that may link to or be accessible from or on
|
||||
the Website
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Please read this policy carefully to understand our policies and practices regarding your
|
||||
information and how we will treat it. If you do not agree with our policies and practices,
|
||||
your choice is not to use our Website. By accessing or using this Website, you agree to this
|
||||
privacy policy. This policy may change from time to time (see Changes to the Privacy Policy).
|
||||
Your continued use of this Website after we make changes is deemed to be acceptance of those
|
||||
changes, so please check the policy periodically for updates.
|
||||
</p>
|
||||
|
||||
<h2>Foreword</h2>
|
||||
|
||||
<p>
|
||||
The following document was created as required by several laws, including but not limited to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
the California Consumer Privacy Act (CA CCPA), more information about which can be found on
|
||||
<a href="https://oag.ca.gov/privacy/ccpa">oag.ca.gov</a>
|
||||
</li>
|
||||
<li>
|
||||
the European Union General Data Protection Regulation (EU GDPR), more information about
|
||||
which can be found on
|
||||
<a href="https://gdpr.eu/">gdpr.eu</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>Rinth, Inc. is the data controller for data collected through Modrinth.</p>
|
||||
|
||||
<h2>What data do we collect?</h2>
|
||||
|
||||
<h3>User data</h3>
|
||||
<p>When you create an account, we collect:</p>
|
||||
<ul>
|
||||
<li>Your email</li>
|
||||
<li>Your username</li>
|
||||
<li>Your display name</li>
|
||||
<li>Your profile picture</li>
|
||||
<li>Your OAuth application data (ex: GitHub or Discord ID)</li>
|
||||
</ul>
|
||||
<p>
|
||||
This data is used to identify you and display your profile. It will be linked to your
|
||||
projects.
|
||||
</p>
|
||||
|
||||
<h3>View data and download data</h3>
|
||||
<p>When you view a project page or download a file from Modrinth, we collect:</p>
|
||||
<ul>
|
||||
<li>Your IP address</li>
|
||||
<li>Your user ID (if applicable)</li>
|
||||
<li>The project viewed and/or the file downloaded</li>
|
||||
<li>Your country</li>
|
||||
<li>Some additional metadata about your connection (HTTP headers)</li>
|
||||
</ul>
|
||||
<p>This data is used to monitor automated access to our service and deliver statistics.</p>
|
||||
|
||||
<h3>Playtime data</h3>
|
||||
<p>When you use the Modrinth App to play Modrinth projects, we collect:</p>
|
||||
<ul>
|
||||
<li>Your IP address</li>
|
||||
<li>Your user ID</li>
|
||||
<li>The amount of time the project was played for</li>
|
||||
<li>The project played</li>
|
||||
<li>
|
||||
Some additional metadata about the projects you're playing (loaders and game versions)
|
||||
</li>
|
||||
</ul>
|
||||
<p>This data is used to deliver statistics.</p>
|
||||
|
||||
<h3>Usage data</h3>
|
||||
<p>When you interact with the Modrinth App or the Website, we collect through MixPanel:</p>
|
||||
<ul>
|
||||
<li>Your IP address</li>
|
||||
<li>Your anonymized user ID</li>
|
||||
<li>The time the interaction happened</li>
|
||||
<li>Some additional metadata about the device you are on</li>
|
||||
<li>Some additional metadata about each interaction</li>
|
||||
</ul>
|
||||
<p>This data is used to deliver statistics.</p>
|
||||
|
||||
<h3>Creator Monetization Program data</h3>
|
||||
<p>
|
||||
When you sign up for our
|
||||
<a href="https://blog.modrinth.com/p/creator-monetization-beta">
|
||||
Creator Monetization Program</a
|
||||
>
|
||||
(the "CMP"), we collect:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your PayPal email address (if applicable)</li>
|
||||
<li>Your Venmo username (if applicable)</li>
|
||||
</ul>
|
||||
<p>This data is used to carry out the CMP. It will be linked to your transactions.</p>
|
||||
|
||||
<h2>Data retention</h2>
|
||||
<p>
|
||||
View data and download data are anonymized 24 months after being recorded. All personal
|
||||
information will be removed from those records during anonymization.<br />
|
||||
Data is retained indefinitely. We do not delete any data unless you request it.
|
||||
</p>
|
||||
|
||||
<h2>Third-party services</h2>
|
||||
<p>
|
||||
We use some third-party services to make Modrinth run. Please refer to each of their privacy
|
||||
policies for more information:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.cloudflare.com/en-gb/gdpr/introduction/"> Cloudflare </a>
|
||||
</li>
|
||||
<li><a href="https://sentry.io/trust/privacy/">Sentry</a></li>
|
||||
<li><a href="https://mixpanel.com/legal/privacy-policy">MixPanel</a></li>
|
||||
<li><a href="https://www.beehiiv.com/privacy">BeeHiiv</a></li>
|
||||
<li><a href="https://www.paypal.com/us/legalhub/privacy-full">PayPal</a></li>
|
||||
</ul>
|
||||
<p>
|
||||
Data that we specifically collect isn't shared with any other third party. We do not sell any
|
||||
data.
|
||||
</p>
|
||||
|
||||
<h2>Data Governance</h2>
|
||||
<p>
|
||||
Database access is limited to the minimum amount of Rinth, Inc. employees required to run the
|
||||
service.<br />
|
||||
Data is stored in a jurisdiction that is part of the European Economic Area (EEA), encrypted
|
||||
both in storage and in transit.
|
||||
</p>
|
||||
|
||||
<h2>Marketing and advertising</h2>
|
||||
<p>
|
||||
We use anonymized statistics to conduct marketing and advertising through
|
||||
<a href="https://adrinth.com/">Adrinth</a>.
|
||||
</p>
|
||||
|
||||
<h2>Cookies</h2>
|
||||
<p>We use cookies to log you into your account and save your cosmetic preferences.</p>
|
||||
<p>
|
||||
Cookies are text files placed on your computer to collect standard Internet information. For
|
||||
more information, please visit
|
||||
<a href="https://allaboutcookies.org/">allaboutcookies.org</a>.
|
||||
</p>
|
||||
<p>
|
||||
You can set your browser not to accept cookies, and the above website tells you how to remove
|
||||
cookies from your browser. However, in a few cases, some of our website features may not
|
||||
function as a result.
|
||||
</p>
|
||||
|
||||
<h2>Access, rectification, erasure, restriction, portability, and objection</h2>
|
||||
<p>Every user is entitled to the following:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>The right to access</strong> – You have the right to request copies of your personal
|
||||
data. We may charge you a small fee for this service.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The right to rectification</strong> – You have the right to request that we correct
|
||||
any information you believe is inaccurate. You also have the right to request us to complete
|
||||
the information you believe is incomplete.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The right to erasure</strong> – You have the right to request that we erase your
|
||||
personal data, under certain conditions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The right to restrict processing</strong> – You have the right to request that we
|
||||
restrict the processing of your personal data, under certain conditions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The right to data portability</strong> – You have the right to request that we
|
||||
transfer the data that we have collected to another organization, or directly to you, under
|
||||
certain conditions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The right to object to processing</strong> – You have the right to object to our
|
||||
processing of your personal data, under certain conditions.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you would like to exercise those rights, contact us at
|
||||
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a>. We may ask you to verify your
|
||||
identity before proceeding and will respond to your request within 30 days as required by law,
|
||||
or notify you of an extended reply time.
|
||||
</p>
|
||||
|
||||
<h2>Children's Information</h2>
|
||||
<p>
|
||||
Another part of our priority is adding protection for children while using the Internet. We
|
||||
encourage parents and guardians to observe, participate in, and/or monitor and guide their
|
||||
online activity.
|
||||
</p>
|
||||
<p>
|
||||
Modrinth does not knowingly collect any Personal Identifiable Information from children under
|
||||
the age of 13. If you think that your child provided this kind of information on our website,
|
||||
we strongly encourage you to contact us immediately and we will do our best efforts to
|
||||
promptly remove such information from our records.
|
||||
</p>
|
||||
|
||||
<h2>Online Privacy Policy Only</h2>
|
||||
<p>
|
||||
This Privacy Policy applies only to our online activities and is valid for visitors to our
|
||||
website with regards to the information that they shared and/or collect in Modrinth. This
|
||||
policy is not applicable to any information collected offline or via channels other than this
|
||||
website.
|
||||
</p>
|
||||
|
||||
<h2>Consent</h2>
|
||||
<p>
|
||||
By using our website, you hereby consent to our Privacy Policy and agree to its Terms and
|
||||
Conditions.
|
||||
</p>
|
||||
|
||||
<h2>California Privacy Rights</h2>
|
||||
<p>
|
||||
If you are a California resident, California law may provide you with additional rights
|
||||
regarding our use of your personal information. To learn more about your California privacy
|
||||
rights, visit <nuxt-link to="/legal/ccpa">this page</nuxt-link>.
|
||||
</p>
|
||||
<p>
|
||||
California's "Shine the Light" law (Civil Code Section § 1798.83) permits users of our App
|
||||
that are California residents to request certain information regarding our disclosure of
|
||||
personal information to third parties for their direct marketing purposes. To make such a
|
||||
request, please send an email to
|
||||
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
|
||||
</p>
|
||||
|
||||
<h2>Changes to the Privacy Policy</h2>
|
||||
<p>
|
||||
We keep this privacy policy under regular review and place any updates on this web page. If we
|
||||
do this, we will post the changes on this page and update the "Last edited" date at the top of
|
||||
this page, after which such changes will become effective immediately. We will make an effort
|
||||
to keep users updated on any such changes, but because most changes do not affect how we
|
||||
process existing data, a notice will not be sent for all changes.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
If you have any questions about this privacy policy or how we process your data, contact us at
|
||||
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a> or write us at:
|
||||
</p>
|
||||
<p>
|
||||
Rinth, Inc.<br />
|
||||
410 North Scottsdale Road<br />
|
||||
Suite 1000<br />
|
||||
Tempe, AZ 85281
|
||||
</p>
|
||||
|
||||
<h3>How to contact the appropriate authority</h3>
|
||||
<p>
|
||||
Should you wish to fill a complaint or if you feel like we haven't addressed your concerns or
|
||||
request, you may contact the
|
||||
<a href="https://ico.org.uk/">Information Commissioner's Office</a>
|
||||
using their online form or by writing at:
|
||||
</p>
|
||||
<p>
|
||||
Information Commissioner's Office<br />
|
||||
Wycliffe House<br />
|
||||
Water Lane<br />
|
||||
Wilmslow<br />
|
||||
Cheshire<br />
|
||||
SK9 5AF<br />
|
||||
United Kingdom
|
||||
</p>
|
||||
<p>
|
||||
You do not need to be a citizen of the United Kingdom to use this method of lodging
|
||||
complaints.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'The Privacy Policy of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Privacy Policy - Modrinth',
|
||||
description,
|
||||
ogTitle: 'Privacy Policy',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
188
apps/frontend/src/pages/legal/rules.vue
Normal file
188
apps/frontend/src/pages/legal/rules.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Content Rules</h1>
|
||||
|
||||
<p>
|
||||
These Content Rules are to be considered part of our
|
||||
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link> and apply to any and all User
|
||||
Contributions, Gaming Content, and use of Interactive Services (collectively, "Content").
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you find any violations of these Rules on our website, you should make us aware. You may
|
||||
use the Report button on any project, version, or user page, or you may email us at
|
||||
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="prohibited-content">1. Prohibited Content</h2>
|
||||
|
||||
<p>
|
||||
Content must in their entirety comply with all applicable federal, state, local, and
|
||||
international laws and regulations. Without limiting the foregoing, Content must not:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
Contain any material which is defamatory, obscene, indecent, abusive, offensive, harassing,
|
||||
violent, hateful, inflammatory, harmful, damaging, disruptive, contradictory, or otherwise
|
||||
objectionable.
|
||||
</li>
|
||||
<li>
|
||||
Promote sexually explicit or pornographic material, violence, or discrimination based on
|
||||
race, sex, gender, religion, nationality, disability, sexual orientation, or age.
|
||||
</li>
|
||||
<li>
|
||||
Infringe any patent, trademark, trade secret, copyright, or other intellectual property or
|
||||
other rights of any other person.
|
||||
</li>
|
||||
<li>
|
||||
Violate the legal rights (including the rights of publicity and privacy) of others or
|
||||
contain any material that could give rise to any civil or criminal liability under
|
||||
applicable laws or regulations or that otherwise may be in conflict with our
|
||||
<nuxt-link to="/legal/terms">Terms of Use</nuxt-link> or
|
||||
<nuxt-link to="/legal/privacy">Privacy Policy</nuxt-link>.
|
||||
</li>
|
||||
<li>
|
||||
Promote any illegal activity, or advocate, promote or assist any unlawful act, including
|
||||
real-life drugs or illicit substances.
|
||||
</li>
|
||||
<li>
|
||||
Cause annoyance, inconvenience, or needless anxiety or be likely to upset, embarrass, alarm,
|
||||
annoy, harm, or deceive any other person.
|
||||
</li>
|
||||
<li>Make or share intentionally wrong or misleading claims.</li>
|
||||
<li>
|
||||
Impersonate any person, or misrepresent your identity or affiliation with any person or
|
||||
organization.
|
||||
</li>
|
||||
<li>
|
||||
Give the impression that they emanate from or are endorsed by us or any other person or
|
||||
entity, if this is not the case.
|
||||
</li>
|
||||
<li>Contain an excessive amount of profane language.</li>
|
||||
<li>
|
||||
Be designed to upload any data to a remote server (i.e. one that the user does not directly
|
||||
choose to connect to in-game) without clear disclosure.
|
||||
</li>
|
||||
<li>
|
||||
Bypass restrictions placed by Mojang to prevent users from joining certain in-game servers.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="clear-and-honest-function">2. Clear and Honest Function</h2>
|
||||
|
||||
<p>
|
||||
Projects, a form of Content, must make a clear and honest attempt to describe their purpose in
|
||||
designated areas on the project page. Necessary information must not be obscured in any way.
|
||||
Using confusing language or technical jargon when it is not necessary constitutes a violation.
|
||||
</p>
|
||||
|
||||
<h3 id="general-expectations">2.1. General Expectations</h3>
|
||||
|
||||
<p>
|
||||
From a project description, users should be able to understand what the project does and how
|
||||
to use it. Projects must attempt to describe the following three things within their
|
||||
description:
|
||||
</p>
|
||||
<ol type="a">
|
||||
<li>what the project specifically does or adds</li>
|
||||
<li>why someone should want to download the project</li>
|
||||
<li>any other critical information the user must know before downloading</li>
|
||||
</ol>
|
||||
|
||||
<h3 id="accessibility">2.2. Accessibility</h3>
|
||||
|
||||
<p>
|
||||
Project descriptions must be accessible so that they can be read through a variety of mediums.
|
||||
All descriptions must have a plain-text version, though images, videos, and other content can
|
||||
take priority if desired. Headers must not be used for body text.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Project descriptions must have an English-language translation unless they are exclusively
|
||||
meant for use in a specific language, such as translation packs. Descriptions may provide
|
||||
translations into other languages if desired.
|
||||
</p>
|
||||
|
||||
<h2 id="cheats-and-hacks">3. Cheats and Hacks</h2>
|
||||
|
||||
<p>
|
||||
Projects cannot contain or download "cheats", which we define as a client-side modification
|
||||
that:
|
||||
</p>
|
||||
<ol>
|
||||
<li>is advertised as a "cheat", "hack", or "hacked client"</li>
|
||||
<li>
|
||||
gives an unfair advantage in a multiplayer setting over other players that do not have a
|
||||
comparable modification and does not provide a server-side opt-out
|
||||
</li>
|
||||
<li>
|
||||
contains any of the following functions without requiring a server-side opt-in:
|
||||
<ol type="a">
|
||||
<li>X-ray or the ability to see through opaque blocks</li>
|
||||
<li>aim bot or aim assist</li>
|
||||
<li>flight, speed, or other movement modifications</li>
|
||||
<li>automatic or assisted PvP combat</li>
|
||||
<li>
|
||||
active client-side hiding of third party modifications that have server-side opt-outs
|
||||
</li>
|
||||
<li>item duplication</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="copyright-and-legality-of-content">4. Copyright and Reuploads</h2>
|
||||
|
||||
<p>
|
||||
You must own or have the necessary licenses, rights, consents, and permissions to store,
|
||||
share, and distribute the Content that is uploaded under your Modrinth account.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Content may not be directly reuploaded from another source without explicit permission from
|
||||
the original author. If explicit permission has been granted, or it is a license-abiding
|
||||
"fork", this restriction does not apply. We define "forks" as modified copies of a project
|
||||
which have diverged substantially from the original project.
|
||||
</p>
|
||||
|
||||
<h2 id="miscellaneous">5. Miscellaneous</h2>
|
||||
|
||||
<p>
|
||||
There are certain other small aspects to creating projects that all authors should attempt to
|
||||
abide by. These will not necessarily always be enforced, but abiding by all will result in a
|
||||
faster review with fewer potential issues.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
All metadata, including license, client/server-side information, tags, etc. are filled out
|
||||
correctly and are consistent with information found elsewhere.
|
||||
</li>
|
||||
<li>
|
||||
Project titles are only the name of the project, without any other unnecessary filler data.
|
||||
</li>
|
||||
<li>
|
||||
Project summaries contain a small summary of the project without any formatting and without
|
||||
repeating the project title.
|
||||
</li>
|
||||
<li>All external links lead to public resources that are relevant.</li>
|
||||
<li>Gallery images are relevant to the project and each contain a title.</li>
|
||||
<li>All dependencies must be specified in the Dependencies section of each version.</li>
|
||||
<li>
|
||||
"Additional files" are only used for special designated purposes, such as source JAR files.
|
||||
In other words, separate versions and/or projects are used where appropriate instead of
|
||||
additional files.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'The Content Rules of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Content Rules - Modrinth',
|
||||
description,
|
||||
ogTitle: 'Content Rules',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
65
apps/frontend/src/pages/legal/security.vue
Normal file
65
apps/frontend/src/pages/legal/security.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Security Notice</h1>
|
||||
|
||||
<p>
|
||||
This is the security notice for all Modrinth repositories. The notice explains how
|
||||
vulnerabilities should be reported.
|
||||
</p>
|
||||
<h2>Reporting a Vulnerability</h2>
|
||||
<p>
|
||||
If you've found a vulnerability, we would like to know so we can fix it before it is released
|
||||
publicly.
|
||||
<strong>Do not open a GitHub issue for a found vulnerability</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Send details to <a href="mailto:jai@modrinth.com">jai@modrinth.com</a>
|
||||
including:
|
||||
</p>
|
||||
<ul>
|
||||
<li>the website, page or repository where the vulnerability can be observed</li>
|
||||
<li>a brief description of the vulnerability</li>
|
||||
<li>
|
||||
optionally the type of vulnerability and any related
|
||||
<a href="https://www.owasp.org/index.php/Category:OWASP_Top_Ten_2017_Project">
|
||||
OWASP category
|
||||
</a>
|
||||
</li>
|
||||
<li>non-destructive exploitation details</li>
|
||||
</ul>
|
||||
<p>We will do our best to reply as fast as possible.</p>
|
||||
<h2>Scope</h2>
|
||||
<p>The following vulnerabilities <strong>are not</strong> in scope:</p>
|
||||
<ul>
|
||||
<li>
|
||||
volumetric vulnerabilities, for example overwhelming a service with a high volume of
|
||||
requests
|
||||
</li>
|
||||
<li>
|
||||
reports indicating that our services do not fully align with "best practice", for example
|
||||
missing security headers
|
||||
</li>
|
||||
</ul>
|
||||
<p>If you aren't sure, you can still reach out via email or direct message.</p>
|
||||
<hr />
|
||||
<p>
|
||||
This notice is inspired by the
|
||||
<a href="https://www.pythondiscord.com/pages/security-notice/">
|
||||
Python Discord Security Notice</a
|
||||
>.
|
||||
</p>
|
||||
<p><em>Version 2022-11</em></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'The Security Notice of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Security Notice - Modrinth',
|
||||
description,
|
||||
ogTitle: 'Security Notice',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
559
apps/frontend/src/pages/legal/terms.vue
Normal file
559
apps/frontend/src/pages/legal/terms.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Terms of Use</h1>
|
||||
<p><em>Last modified: September 16, 2023</em></p>
|
||||
<h2>Acceptance of the Terms of Use</h2>
|
||||
<p>
|
||||
These terms of use are entered into by and between You and Rinth, Inc.
|
||||
(<strong>"Company"</strong>, <strong>"we"</strong> or <strong>"us"</strong>). The following
|
||||
terms and conditions, together with any documents they expressly incorporate by reference
|
||||
(collectively, these "Terms of Use"), govern your access to and use of www.modrinth.com,
|
||||
api.modrinth.com, or the Modrinth App, including any content, functionality and services
|
||||
offered on or through www.modrinth.com, api.modrinth.com, or the Modrinth App (the "Service"),
|
||||
whether as a guest or a registered user.
|
||||
</p>
|
||||
<p>
|
||||
Please read the Terms of Use carefully before you start to use the Service. By accessing or
|
||||
using the Service, you accept and agree to be bound and abide by these Terms of Use, our
|
||||
Privacy Policy, our Copyright Policy, and our California Privacy Notice. If you do not want to
|
||||
agree to these terms and policies, you must not access or use the Service.
|
||||
</p>
|
||||
<p>
|
||||
The Service is offered and available to users who are 13 years of age or older. By using the
|
||||
Service, you represent and warrant that you are of legal age to form a binding contract with
|
||||
the Company and meet all of the foregoing eligibility requirements. If you do not meet all of
|
||||
these requirements, you must not access or use the Service.
|
||||
</p>
|
||||
<h2>Changes to the Terms of Use</h2>
|
||||
<p>
|
||||
We may revise and update these Terms of Use from time to time in our sole discretion. All
|
||||
changes are effective immediately when we post them, and apply to all access to and use of the
|
||||
Service thereafter.
|
||||
</p>
|
||||
<p>
|
||||
Your continued use of the Service following the posting of revised Terms of Use means that you
|
||||
accept and agree to the changes. You are expected to check this page each time you access the
|
||||
Service so you are aware of any changes, as they are binding on you.
|
||||
</p>
|
||||
<h2>Accessing the Service and Account Security</h2>
|
||||
<p>
|
||||
We reserve the right to withdraw or amend the Service, and any service or material we provide
|
||||
through the Service, in our sole discretion without notice. We will not be liable if for any
|
||||
reason all or any part of the Service is unavailable at any time or for any period. From time
|
||||
to time, we may restrict access to some parts of the Service, or the entire Service, to users,
|
||||
including registered users.
|
||||
</p>
|
||||
<p>
|
||||
You are responsible for making all arrangements necessary for you to have access to the
|
||||
Service, and ensuring that all persons who access the Service through your internet connection
|
||||
are aware of these Terms of Use and comply with them.
|
||||
</p>
|
||||
<p>
|
||||
To access the Service or some of the resources it offers, you may be asked to provide certain
|
||||
registration details or other information. It is a condition of your use of the Service that
|
||||
all the information you provide on the Service is correct, current and complete. You agree
|
||||
that all information you provide to register with the Service or otherwise, including but not
|
||||
limited to through the use of any interactive features on the Service, is governed by our
|
||||
Privacy Policy, and you consent to all actions we take with respect to your information
|
||||
consistent with our Privacy Policy.
|
||||
</p>
|
||||
<p>
|
||||
If you choose, or are provided with, a email, password or any other piece of information as
|
||||
part of our security procedures, you must treat such information as confidential, and you must
|
||||
not disclose it to any other person or entity. You also acknowledge that your account is
|
||||
personal to you and agree not to provide any other person with access to the Service or
|
||||
portions of it using your user name, password or other security information (and if you do,
|
||||
you agree to be responsible for and to indemnify, defend and hold us harmless from any damages
|
||||
caused by any such person to whom you provide any such information). You agree to notify us
|
||||
immediately of any unauthorized access to or use of your user name or password or any other
|
||||
breach of security. You should use particular caution when accessing your account from a
|
||||
public or shared computer so that others are not able to view or record your password or other
|
||||
personal information.
|
||||
</p>
|
||||
<p>
|
||||
We have the right to disable any user name, password or other identifier, whether chosen by
|
||||
you or provided by us, at any time in our sole discretion for any or no reason, including if,
|
||||
in our opinion, you have violated any provision of these Terms of Use.
|
||||
</p>
|
||||
<h2>About the Service</h2>
|
||||
<p>
|
||||
The Service allows you to upload and share your gaming content (the "Gaming Content", to be
|
||||
distinguished from User Contributions, defined below) with other users of our Service. Unlike
|
||||
User Contributions, which are owned by the Company, when you use the Service and upload and
|
||||
share your Gaming Content, the Company does not claim any right, title or interest in or to
|
||||
your Gaming Content. At the same time, the Company disclaims any and all liability in
|
||||
connection with the Gaming Content, and you are solely responsible for any and all claims,
|
||||
actions, damages and the like in connection with the Gaming Content.
|
||||
</p>
|
||||
<p>
|
||||
When you upload your Gaming Content, you hereby grant us a limited use, non-exclusive,
|
||||
royalty-free, fully paid up, right to display and distribute your Gaming Content to our users
|
||||
through the Service, which our users in turn may then use at their sole discretion.
|
||||
</p>
|
||||
<p>
|
||||
When you delete your Gaming Content, you agree that your Gaming Content may continue to be
|
||||
used by any users who have previously downloaded your Gaming Content pursuant to the preceding
|
||||
license, and you also agree that your Gaming Content may continue to be part of any
|
||||
already-existing Gaming Content "packs" created by other users as part of our Service until
|
||||
such time as such Gaming Content has been deleted by such user(s) as part of such "pack(s)".
|
||||
Otherwise, for the avoidance of doubt, as between the Company and you, you own and will
|
||||
continue to own all right, title and interest in and to your Gaming Content.
|
||||
</p>
|
||||
<p>
|
||||
All Gaming Content must abide by the <nuxt-link to="/legal/rules">Content Rules</nuxt-link>.
|
||||
</p>
|
||||
<h2>API Usage</h2>
|
||||
<p>
|
||||
As part of our Services, we provide a tool known as Application Programming Interfaces
|
||||
(Modrinth API). The Modrinth API enables users to perform actions on the Service via their own
|
||||
services, such as their websites and/or applications.
|
||||
</p>
|
||||
<p>
|
||||
We grant the User of the Modrinth API a limited, non-exclusive, non-sublicensable and
|
||||
revocable license to download, display, query, create, edit, and delete the User Generated
|
||||
Content on the Service via their own services, such as their websites and/or applications (the
|
||||
API License), provided the User complies with the following conditions:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
The User uses the Modrinth API in accordance with these Terms, the Community Standards and
|
||||
all applicable laws and regulations that apply to such use.
|
||||
</li>
|
||||
<li>the User does not infringe any rights of third parties.</li>
|
||||
<li>
|
||||
the User provides Modrinth all assistance requested in complying with, and concluding all
|
||||
agreements required by the applicable laws and regulations.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Each User using the Modrinth API shall indemnify Modrinth and keep Modrinth indemnified for
|
||||
all claims, proceedings or actions brought by another User or any other third party, including
|
||||
governmental authorities.
|
||||
</p>
|
||||
<h2>Our Rewards Program</h2>
|
||||
<p>
|
||||
Our rewards program (<strong>"Rewards Program"</strong>) provides developers and content
|
||||
creators with an opportunity to monetize the projects that they upload to the Service.
|
||||
</p>
|
||||
<p>
|
||||
The Company pays to you a percentage of net revenue collected by us and attributable to ad
|
||||
impressions displayed solely on your project pages. The funds are directly deposited into your
|
||||
account, which you can monitor and access via your creator dashboard. Simply enroll in the
|
||||
Rewards Program on your dashboard in order to be able to take advantage of this program.
|
||||
Please review the <nuxt-link to="/legal/cmp">Rewards Program Terms</nuxt-link>, as it contains
|
||||
very important language regarding, among other things, your status as an independent
|
||||
contractor, your responsibility to pay taxes, our disclaimers and limitations of liability (in
|
||||
addition to our disclaimers and limitations of liability in these Terms of Use).
|
||||
</p>
|
||||
<h2>Intellectual Property Rights</h2>
|
||||
<p>
|
||||
Except for the Gaming Content, the Service and its entire contents, features and functionality
|
||||
(including but not limited to all information, software, text, displays, images, video and
|
||||
audio, and the design, selection and arrangement thereof), are owned by the Company, its
|
||||
licensors or other providers of such material and are protected by United States and
|
||||
international copyright, trademark, patent, trade secret and other intellectual property or
|
||||
proprietary rights laws.
|
||||
</p>
|
||||
<p>
|
||||
These Terms of Use permit you to use the Service for your personal, non-commercial use only.
|
||||
</p>
|
||||
<p>
|
||||
No right, title or interest in or to the Service or any content contained therein is
|
||||
transferred to you, and all rights not expressly granted are reserved by the Company. Any use
|
||||
of the Service not expressly permitted by these Terms of Use is a breach of these Terms of Use
|
||||
and may violate copyright, trademark and other laws.
|
||||
</p>
|
||||
<h2>Trademarks</h2>
|
||||
<p>
|
||||
The Company name, the terms Modrinth, the Company logo and all related names, logos, product
|
||||
and service names, designs and slogans are trademarks of the Company or its affiliates or
|
||||
licensors. You must not use such marks without the prior written permission of the Company.
|
||||
All other names, logos, product and service names, designs and slogans contained in the
|
||||
Service are the trademarks of their respective owners.
|
||||
</p>
|
||||
<h2>Prohibited Uses</h2>
|
||||
<p>
|
||||
You may use the Service only for lawful purposes and in accordance with these Terms of Use.
|
||||
You agree not to use the Service:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
In any way that violates any applicable federal, state, local or international law or
|
||||
regulation (including, without limitation, any laws regarding the export of data or software
|
||||
to and from the US or other countries).
|
||||
</li>
|
||||
<li>
|
||||
For the purpose of exploiting, harming or attempting to exploit or harm minors in any way by
|
||||
exposing them to inappropriate content, asking for personally identifiable information or
|
||||
otherwise.
|
||||
</li>
|
||||
<li>
|
||||
To send, knowingly receive, upload, download, use, or re-use any material which does not
|
||||
comply with the <nuxt-link to="/legal/rules">Content Rules</nuxt-link>.
|
||||
</li>
|
||||
<li>
|
||||
To transmit, or procure the sending of, any advertising or promotional material, including
|
||||
any "junk mail", "chain letter" or "spam" or any other similar solicitation.
|
||||
</li>
|
||||
<li>
|
||||
To impersonate or attempt to impersonate the Company, a Company employee, another user or
|
||||
any other person or entity (including, without limitation, by using e-mail addresses or
|
||||
screen names associated with any of the foregoing).
|
||||
</li>
|
||||
<li>
|
||||
To engage in any other conduct that restricts or inhibits anyone's use or enjoyment of the
|
||||
Service, or which, as determined by us, may harm the Company or users of the Service or
|
||||
expose them to liability.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Additionally, you agree not to:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Use the Service in any manner that could disable, overburden, damage, or impair the site or
|
||||
interfere with any other party's use of the Service, including their ability to engage in
|
||||
real time activities through the Service.
|
||||
</li>
|
||||
<li>
|
||||
Use any robot, spider or other automatic device, process or means to access the Service for
|
||||
any purpose, including monitoring or copying any of the material contained in the Service.
|
||||
</li>
|
||||
<li>
|
||||
Use any manual process to monitor or copy any of the material contained in the Service or
|
||||
for any other unauthorized purpose without our prior written consent.
|
||||
</li>
|
||||
<li>
|
||||
Use any device, software or routine that interferes with the proper working of the Service.
|
||||
</li>
|
||||
<li>
|
||||
Introduce any viruses, trojan horses, worms, logic bombs or other material which is
|
||||
malicious or technologically harmful.
|
||||
</li>
|
||||
<li>
|
||||
Attempt to gain unauthorized access to, interfere with, damage or disrupt any parts of the
|
||||
Service, the server on which the Service is stored, or any server, computer or database
|
||||
connected to the Service.
|
||||
</li>
|
||||
<li>
|
||||
Attack the Service via a denial-of-service attack or a distributed denial-of-service attack.
|
||||
</li>
|
||||
<li>Otherwise attempt to interfere with the proper working of the Service.</li>
|
||||
</ul>
|
||||
<h2>User Contributions</h2>
|
||||
<p>
|
||||
The Service may contain message boards, chat rooms, personal web pages or profiles, forums,
|
||||
bulletin boards and other interactive features (collectively,
|
||||
<strong>"Interactive Services"</strong>) that allow users to post, submit, publish, display or
|
||||
transmit to other users or other persons (hereinafter, <strong>"post"</strong>) content (other
|
||||
than Gaming Content, as defined above) or materials (collectively,
|
||||
<strong>"User Contributions"</strong>) on or through the Service.
|
||||
</p>
|
||||
<p>
|
||||
All User Contributions and use of Interactive Services must abide by the
|
||||
<nuxt-link to="/legal/rules">Content Rules</nuxt-link>.
|
||||
</p>
|
||||
<p>
|
||||
Any User Contribution you post to the site will be considered non-confidential and
|
||||
non-proprietary. By providing any User Contribution through the Service, you grant us and our
|
||||
affiliates and service providers, and each of their and our respective licensees, successors
|
||||
and assigns the right to use, reproduce, modify, perform, display, distribute and otherwise
|
||||
disclose to third parties any such material for any purpose consistent with your account
|
||||
settings.
|
||||
</p>
|
||||
<p>You represent and warrant that:</p>
|
||||
<ul>
|
||||
<li>
|
||||
You own or control all rights in and to the User Contributions and have the right to grant
|
||||
the license granted above to us and our affiliates and service providers, and each of their
|
||||
and our respective licensees, successors and assigns.
|
||||
</li>
|
||||
<li>All of your User Contributions do and will comply with these Terms of Use.</li>
|
||||
</ul>
|
||||
<p>
|
||||
You understand and acknowledge that you are responsible for any User Contributions you submit
|
||||
or contribute, and you, not the Company, have full responsibility for such content, including
|
||||
its legality, reliability, accuracy and appropriateness.
|
||||
</p>
|
||||
<p>
|
||||
We are not responsible, or liable to any third party, for the content or accuracy of any User
|
||||
Contributions posted by you or any other user of the Service.
|
||||
</p>
|
||||
<h2>Monitoring and Enforcement; Termination</h2>
|
||||
<p>We have the right to:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Remove or refuse to post any User Contributions for any or no reason at our sole discretion.
|
||||
</li>
|
||||
<li>
|
||||
Take any action with respect to any User Contribution that we deem necessary or appropriate
|
||||
in our sole discretion, including if we believe that such User Contribution violates the
|
||||
Terms of Use, including the Content Rules, infringes any intellectual property right or
|
||||
other right of any person or entity, threatens the personal safety of users of the Service
|
||||
or the public or could create liability for the Company.
|
||||
</li>
|
||||
<li>
|
||||
Take appropriate legal action, including without limitation, referral to law enforcement,
|
||||
for any illegal or unauthorized use of the Service.
|
||||
</li>
|
||||
<li>
|
||||
Terminate or suspend your access to all or part of the Service for any or no reason,
|
||||
including without limitation, any violation of these Terms of Use.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Without limiting the foregoing, we have the right to fully cooperate with any law enforcement
|
||||
authorities or court order requesting or directing us to disclose the identity or other
|
||||
information of anyone posting any materials on or through the Service. YOU WAIVE AND HOLD
|
||||
HARMLESS THE COMPANY AND ITS AFFILIATES, LICENSEES AND SERVICE PROVIDERS FROM ANY CLAIMS
|
||||
RESULTING FROM ANY ACTION TAKEN BY ANY OF THE FOREGOING PARTIES DURING OR AS A RESULT OF ITS
|
||||
INVESTIGATIONS AND FROM ANY ACTIONS TAKEN AS A CONSEQUENCE OF INVESTIGATIONS BY EITHER SUCH
|
||||
PARTIES OR LAW ENFORCEMENT AUTHORITIES.
|
||||
</p>
|
||||
<p>
|
||||
However, we cannot ensure prompt removal of objectionable material after it has been posted.
|
||||
Accordingly, we assume no liability for any action or inaction regarding transmissions,
|
||||
communications or content provided by any user or third party. We have no liability or
|
||||
responsibility to anyone for performance or nonperformance of the activities described in this
|
||||
section.
|
||||
</p>
|
||||
<h2>Copyright Infringement</h2>
|
||||
<p>
|
||||
If you believe that any User Contributions violate your copyright, please see our Copyright
|
||||
Policy for instructions on sending us a notice of copyright infringement. It is the policy of
|
||||
the Company to terminate the user accounts of repeat infringers.
|
||||
</p>
|
||||
<h2>Reliance on Information Posted</h2>
|
||||
<p>
|
||||
The information presented on or through the Service is made available solely for general
|
||||
information purposes. We do not warrant the accuracy, completeness or usefulness of this
|
||||
information. Any reliance you place on such information is strictly at your own risk. We
|
||||
disclaim all liability and responsibility arising from any reliance placed on such materials
|
||||
by you or any other visitor to the Service, or by anyone who may be informed of any of its
|
||||
contents.
|
||||
</p>
|
||||
<p>
|
||||
The Service includes content provided by third parties, including materials provided by other
|
||||
users, bloggers and third-party licensors, syndicators, aggregators and/or reporting services.
|
||||
All statements and/or opinions expressed in these materials, and all articles and responses to
|
||||
questions and other content, other than the content provided by the Company, are solely the
|
||||
opinions and the responsibility of the person or entity providing those materials. These
|
||||
materials do not necessarily reflect the opinion of the Company. We are not responsible, or
|
||||
liable to you or any third party, for the content or accuracy of any materials provided by any
|
||||
third parties.
|
||||
</p>
|
||||
<h2>Changes to the Service</h2>
|
||||
<p>
|
||||
We may update the content on the Service from time to time, but its content is not necessarily
|
||||
complete or up-to-date. Any of the material on the Service may be out of date at any given
|
||||
time, and we are under no obligation to update such material.
|
||||
</p>
|
||||
<h2>Linking to the Service and Social Media Features</h2>
|
||||
<p>
|
||||
You may link to our website, provided you do so in a way that is fair and legal and does not
|
||||
damage our reputation or take advantage of it, but you must not establish a link in such a way
|
||||
as to suggest any form of association, approval or endorsement on our part.
|
||||
</p>
|
||||
<p>The Service may provide certain social media features that enable you to:</p>
|
||||
<ul>
|
||||
<li>Link from your own or certain third-party websites to certain content on the Service.</li>
|
||||
<li>
|
||||
Send e-mails or other communications with certain content, or links to certain content,
|
||||
contained in the Service.
|
||||
</li>
|
||||
<li>
|
||||
Cause limited portions of content contained in the Service to be displayed or appear to be
|
||||
displayed on your own or certain third-party websites.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
You may use these features solely as they are provided by us, and solely with respect to the
|
||||
content they are displayed with and otherwise in accordance with any additional terms and
|
||||
conditions we provide with respect to such features. Subject to the foregoing, you must not:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Cause the Service or portions of it to be displayed, or appear to be displayed by, for
|
||||
example, framing, deep linking or in-line linking, on any other site.
|
||||
</li>
|
||||
<li>
|
||||
Otherwise take any action with respect to the materials contained in the Service that is
|
||||
inconsistent with any other provision of these Terms of Use.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
The website from which you are linking, or on which you make certain content accessible, must
|
||||
comply in all respects with the <nuxt-link to="/legal/rules">Content Rules</nuxt-link>.
|
||||
</p>
|
||||
<p>
|
||||
You agree to cooperate with us in causing any unauthorized framing or linking immediately to
|
||||
cease. We reserve the right to withdraw linking permission without notice.
|
||||
</p>
|
||||
<p>
|
||||
We may disable all or any social media features and any links at any time without notice at
|
||||
our discretion.
|
||||
</p>
|
||||
<h2>Links from the Service</h2>
|
||||
<p>
|
||||
If the Service contains links to other sites and resources provided by third parties, these
|
||||
links are provided for your convenience only. This includes links contained in advertisements,
|
||||
including banner advertisements and sponsored links. We have no control over the contents of
|
||||
those sites or resources, and accept no responsibility for them or for any loss or damage that
|
||||
may arise from your use of them. If you decide to access any of the third party websites
|
||||
linked to this Service, you do so entirely at your own risk and subject to the terms and
|
||||
conditions of use for such websites.
|
||||
</p>
|
||||
<h2>Geographic Restrictions</h2>
|
||||
<p>
|
||||
The owner of the Service is based in the state of Delaware in the United States. Access to the
|
||||
Service may not be legal by certain persons or in certain countries. If you access the Service
|
||||
from outside the United States, you do so on your own initiative and are responsible for
|
||||
compliance with local laws.
|
||||
</p>
|
||||
<h2>Disclaimer of Warranties</h2>
|
||||
<p>
|
||||
You understand that we cannot and do not guarantee or warrant that files available for
|
||||
downloading from the internet or the Service will be free of viruses or other destructive
|
||||
code. You are responsible for implementing sufficient procedures and checkpoints to satisfy
|
||||
your particular requirements for anti-virus protection and accuracy of data input and output,
|
||||
and for maintaining a means external to our site for any reconstruction of any lost data. WE
|
||||
WILL NOT BE LIABLE FOR ANY LOSS OR DAMAGE CAUSED BY A DISTRIBUTED DENIAL-OF-SERVICE ATTACK,
|
||||
VIRUSES OR OTHER TECHNOLOGICALLY HARMFUL MATERIAL THAT MAY INFECT YOUR COMPUTER EQUIPMENT,
|
||||
COMPUTER PROGRAMS, DATA OR OTHER PROPRIETARY MATERIAL DUE TO YOUR USE OF THE SERVICE OR ANY
|
||||
SERVICES OR ITEMS OBTAINED THROUGH THE SERVICE OR TO YOUR DOWNLOADING OF ANY MATERIAL POSTED
|
||||
ON IT, OR ON ANY WEBSITE LINKED TO IT.
|
||||
</p>
|
||||
<p>
|
||||
YOUR USE OF THE SERVICE, ITS CONTENT AND ANY SERVICES OR ITEMS OBTAINED THROUGH THE SERVICE IS
|
||||
AT YOUR OWN RISK. THE SERVICE, ITS CONTENT AND ANY SERVICES OR ITEMS OBTAINED THROUGH THE
|
||||
SERVICE ARE PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS, WITHOUT ANY WARRANTIES OF ANY
|
||||
KIND, EITHER EXPRESS OR IMPLIED. NEITHER THE COMPANY NOR ANY PERSON ASSOCIATED WITH THE
|
||||
COMPANY MAKES ANY WARRANTY OR REPRESENTATION WITH RESPECT TO THE COMPLETENESS, SECURITY,
|
||||
RELIABILITY, QUALITY, ACCURACY OR AVAILABILITY OF THE SERVICE. WITHOUT LIMITING THE FOREGOING,
|
||||
NEITHER THE COMPANY NOR ANYONE ASSOCIATED WITH THE COMPANY REPRESENTS OR WARRANTS THAT THE
|
||||
SERVICE, ITS CONTENT OR ANY SERVICES OR ITEMS OBTAINED THROUGH THE SERVICE WILL BE ACCURATE,
|
||||
RELIABLE, ERROR-FREE OR UNINTERRUPTED, THAT DEFECTS WILL BE CORRECTED, THAT THE SERVICE OR THE
|
||||
SERVER THAT MAKES IT AVAILABLE ARE FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS OR THAT THE
|
||||
SERVICE OR ANY SERVICES OR ITEMS OBTAINED THROUGH THE SERVICE WILL OTHERWISE MEET YOUR NEEDS
|
||||
OR EXPECTATIONS.
|
||||
</p>
|
||||
<p>
|
||||
THE COMPANY HEREBY DISCLAIMS ALL WARRANTIES OF ANY KIND, WHETHER EXPRESS OR IMPLIED, STATUTORY
|
||||
OR OTHERWISE, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT
|
||||
AND FITNESS FOR PARTICULAR PURPOSE.
|
||||
</p>
|
||||
<p>
|
||||
THE FOREGOING DOES NOT AFFECT ANY WARRANTIES WHICH CANNOT BE EXCLUDED OR LIMITED UNDER
|
||||
APPLICABLE LAW.
|
||||
</p>
|
||||
<h2>Limitation on Liability</h2>
|
||||
<p>
|
||||
IN NO EVENT WILL THE COMPANY, ITS AFFILIATES OR THEIR LICENSORS, SERVICE PROVIDERS, EMPLOYEES,
|
||||
AGENTS, OFFICERS OR DIRECTORS BE LIABLE FOR DAMAGES OF ANY KIND, UNDER ANY LEGAL THEORY,
|
||||
ARISING OUT OF OR IN CONNECTION WITH YOUR USE, OR INABILITY TO USE, THE SERVICE, ANY WEBSITES
|
||||
LINKED TO IT, ANY CONTENT ON THE SERVICE OR SUCH OTHER WEBSITES OR ANY SERVICES OR ITEMS
|
||||
OBTAINED THROUGH THE SERVICE OR SUCH OTHER WEBSITES, INCLUDING ANY DIRECT, INDIRECT, SPECIAL,
|
||||
INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO, PERSONAL INJURY,
|
||||
PAIN AND SUFFERING, EMOTIONAL DISTRESS, LOSS OF REVENUE, LOSS OF PROFITS, LOSS OF BUSINESS OR
|
||||
ANTICIPATED SAVINGS, LOSS OF USE, LOSS OF GOODWILL, LOSS OF DATA, AND WHETHER CAUSED BY TORT
|
||||
(INCLUDING NEGLIGENCE), BREACH OF CONTRACT OR OTHERWISE, EVEN IF FORESEEABLE.
|
||||
</p>
|
||||
<p>
|
||||
IN NO EVENT WILL THE COMPANY, ITS AFFILIATES OR THEIR LICENSORS, SERVICE PROVIDERS, EMPLOYEES,
|
||||
AGENTS, OFFICERS OR DIRECTORS BE LIABLE FOR DAMAGES OF ANY KIND, UNDER ANY LEGAL THEORY
|
||||
WHATSOEVER, FOR DAMAGES TO YOU AND/OR ANY OTHER PARTY WHATSOEVER (TAKEN IN THE AGGREGATE) IN
|
||||
EXCESS OF THE AMOUNTS RECEIVED FROM YOU DURING THE THREE (3) MONTH PERIOD PRIOR TO THE EVENT
|
||||
GIVING RISE TO THE CLAIM.
|
||||
</p>
|
||||
<p>
|
||||
THE FOREGOING DOES NOT AFFECT ANY LIABILITY WHICH CANNOT BE EXCLUDED OR LIMITED UNDER
|
||||
APPLICABLE LAW.
|
||||
</p>
|
||||
<h2>Indemnification</h2>
|
||||
<p>
|
||||
You agree to defend, indemnify and hold harmless the Company, its affiliates, licensors and
|
||||
service providers, and its and their respective officers, directors, employees, contractors,
|
||||
agents, licensors, suppliers, successors and assigns from and against any claims, liabilities,
|
||||
damages, judgments, awards, losses, costs, expenses or fees (including reasonable attorneys'
|
||||
fees and court costs) arising out of or relating to your violation of these Terms of Use or
|
||||
your use of the Services, including, but not limited to, your User Contributions, any use of
|
||||
the Service's content, services and products other than as expressly authorized in these Terms
|
||||
of Use or your use of any information obtained from the Service.
|
||||
</p>
|
||||
<h2>Governing Law and Jurisdiction</h2>
|
||||
<p>
|
||||
All matters relating to the Service and these Terms of Use and any dispute or claim arising
|
||||
therefrom or related thereto (in each case, including non-contractual disputes or claims),
|
||||
shall be governed by and construed in accordance with the internal laws of the State of
|
||||
Delaware without giving effect to any choice or conflict of law provision or rule (whether of
|
||||
the State of Delaware or any other jurisdiction). Any legal suit, action or proceeding arising
|
||||
out of, or related to, these Terms of Use or the Service shall be instituted exclusively in
|
||||
the federal courts of the United States or the courts of the State of Delaware in each case
|
||||
located in Delaware, although we retain the right to bring any suit, action or proceeding
|
||||
against you for breach of these Terms of Use in your country of residence or any other
|
||||
relevant country. You irrevocably waive any and all objections to the exercise of jurisdiction
|
||||
over you by such courts and to venue in such courts.
|
||||
</p>
|
||||
<h2>Arbitration</h2>
|
||||
<p>
|
||||
At the Company's sole discretion, it may require You to submit any disputes arising from the
|
||||
use of these Terms of Use or the Service, including disputes arising from or concerning their
|
||||
interpretation, violation, invalidity, non-performance, or termination, to final and binding
|
||||
arbitration under the Rules of Arbitration of the American Arbitration Association applying
|
||||
Delaware law.
|
||||
</p>
|
||||
<h2>Limitation on Time to File Claims</h2>
|
||||
<p>
|
||||
ANY CAUSE OF ACTION OR CLAIM YOU MAY HAVE ARISING OUT OF OR RELATING TO THESE TERMS OF USE OR
|
||||
THE WEBSITE MUST BE COMMENCED WITHIN ONE (1) YEAR AFTER THE CAUSE OF ACTION ACCRUES,
|
||||
OTHERWISE, SUCH CAUSE OF ACTION OR CLAIM IS PERMANENTLY BARRED.
|
||||
</p>
|
||||
<h2>Waiver and Severability</h2>
|
||||
<p>
|
||||
No waiver of by the Company of any term or condition set forth in these Terms of Use shall be
|
||||
deemed a further or continuing waiver of such term or condition or a waiver of any other term
|
||||
or condition, and any failure of the Company to assert a right or provision under these Terms
|
||||
of Use shall not constitute a waiver of such right or provision. If any provision of these
|
||||
Terms of Use is held by a court or other tribunal of competent jurisdiction to be invalid,
|
||||
illegal or unenforceable for any reason, such provision shall be eliminated or limited to the
|
||||
minimum extent such that the remaining provisions of the Terms of Use will continue in full
|
||||
force and effect.
|
||||
</p>
|
||||
<h2>Entire Agreement</h2>
|
||||
<p>
|
||||
The <strong>Terms of Use</strong>, our <strong>Privacy Policy</strong>, our
|
||||
<strong>Copyright Policy</strong>, our <strong>Content Guidelines</strong>, and our
|
||||
<strong>California Privacy Notice</strong> (if applicable) constitute the sole and entire
|
||||
agreement between you and the Company with respect to the Service and supersede all prior and
|
||||
contemporaneous understandings, agreements, representations and warranties, both written and
|
||||
oral, with respect to the Service.
|
||||
</p>
|
||||
<h2>Your Comments and Concerns</h2>
|
||||
<p>This website is operated by:</p>
|
||||
<p>
|
||||
Rinth, Inc.<br />
|
||||
410 North Scottsdale Road<br />
|
||||
Suite 1000<br />
|
||||
Tempe, AZ 85281
|
||||
</p>
|
||||
<p>
|
||||
All notices of copyright infringement claims should be sent to the copyright agent designated
|
||||
in our Copyright Policy in the manner and by the means set forth therein.
|
||||
</p>
|
||||
<p>
|
||||
All other feedback, comments, requests for technical support and other communications relating
|
||||
to the Service should be directed to:
|
||||
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const description =
|
||||
'The Terms of Use of Modrinth, an open source modding platform focused on Minecraft.'
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Terms of Use - Modrinth',
|
||||
description,
|
||||
ogTitle: 'Terms of Use',
|
||||
ogDescription: description,
|
||||
})
|
||||
</script>
|
||||
35
apps/frontend/src/pages/moderation.vue
Normal file
35
apps/frontend/src/pages/moderation.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Moderation</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/moderation" label="Overview">
|
||||
<ModrinthIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/review" label="Review projects">
|
||||
<ModerationIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/reports" label="Reports">
|
||||
<ReportIcon />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
|
||||
import ModrinthIcon from '~/assets/images/utils/modrinth.svg?component'
|
||||
import ModerationIcon from '~/assets/images/sidebar/admin.svg?component'
|
||||
import ReportIcon from '~/assets/images/utils/report.svg?component'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
42
apps/frontend/src/pages/moderation/index.vue
Normal file
42
apps/frontend/src/pages/moderation/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Statistics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Projects</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.projects, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Versions</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.versions, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Files</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.files, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Authors</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.authors, false) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatNumber } from '~/plugins/shorthands.js'
|
||||
|
||||
useHead({
|
||||
title: 'Staff overview - Modrinth',
|
||||
})
|
||||
|
||||
const { data: stats } = await useAsyncData('statistics', () => useBaseFetch('statistics'))
|
||||
</script>
|
||||
17
apps/frontend/src/pages/moderation/report/[id].vue
Normal file
17
apps/frontend/src/pages/moderation/report/[id].vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<ReportView
|
||||
:auth="auth"
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportView from '~/components/ui/report/ReportView.vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
const route = useNativeRoute()
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
16
apps/frontend/src/pages/moderation/reports.vue
Normal file
16
apps/frontend/src/pages/moderation/reports.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Reports</h2>
|
||||
<ReportsList :auth="auth" moderation />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportsList from '~/components/ui/report/ReportsList.vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
useHead({
|
||||
title: 'Reports - Modrinth',
|
||||
})
|
||||
</script>
|
||||
287
apps/frontend/src/pages/moderation/review.vue
Normal file
287
apps/frontend/src/pages/moderation/review.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<section class="universal-card">
|
||||
<h2>Review projects</h2>
|
||||
<div class="input-group">
|
||||
<Chips
|
||||
v-model="projectType"
|
||||
:items="projectTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x) + 's')"
|
||||
/>
|
||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
||||
<SortDescIcon />Sorting by oldest
|
||||
</button>
|
||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
||||
<SortAscIcon />Sorting by newest
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-highlight"
|
||||
:disabled="projectsFiltered.length === 0"
|
||||
@click="goToProjects()"
|
||||
>
|
||||
<ModerationIcon /> Start moderating
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="projectType !== 'all'" class="project-count">
|
||||
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
|
||||
projects in the queue.
|
||||
</p>
|
||||
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
|
||||
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
|
||||
<WarningIcon /> {{ projectsOver24Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 24 hours.
|
||||
</p>
|
||||
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
|
||||
<WarningIcon /> {{ projectsOver48Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 48 hours.
|
||||
</p>
|
||||
<div
|
||||
v-for="project in projectsFiltered.sort((a, b) => {
|
||||
if (oldestFirst) {
|
||||
return b.age - a.age
|
||||
} else {
|
||||
return a.age - b.age
|
||||
}
|
||||
})"
|
||||
:key="`project-${project.id}`"
|
||||
class="universal-card recessed project"
|
||||
>
|
||||
<div class="project-title">
|
||||
<div class="mobile-row">
|
||||
<nuxt-link
|
||||
:to="`/${project.inferred_project_type}/${project.slug}`"
|
||||
class="iconified-stacked-link"
|
||||
>
|
||||
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
|
||||
<span class="stacked">
|
||||
<span class="title">{{ project.name }}</span>
|
||||
<span>{{ $formatProjectType(project.inferred_project_type) }}</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
by
|
||||
<nuxt-link
|
||||
v-if="project.owner"
|
||||
:to="`/user/${project.owner.user.username}`"
|
||||
class="iconified-link"
|
||||
>
|
||||
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
|
||||
<span>{{ project.owner.user.username }}</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="project.org"
|
||||
:to="`/organization/${project.org.slug}`"
|
||||
class="iconified-link"
|
||||
>
|
||||
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
|
||||
<span>{{ project.org.name }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
is requesting to be
|
||||
<Badge :type="project.requested_status ? project.requested_status : 'approved'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<nuxt-link
|
||||
:to="`/${project.inferred_project_type}/${project.slug}`"
|
||||
class="iconified-button raised-button"
|
||||
><EyeIcon /> View project</nuxt-link
|
||||
>
|
||||
</div>
|
||||
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
|
||||
<WarningIcon v-if="project.age_warning" />
|
||||
Submitted
|
||||
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
|
||||
fromNow(project.queued)
|
||||
}}</span>
|
||||
</span>
|
||||
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import Chips from '~/components/ui/Chips.vue'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import UnknownIcon from '~/assets/images/utils/unknown.svg?component'
|
||||
import EyeIcon from '~/assets/images/utils/eye.svg?component'
|
||||
import SortAscIcon from '~/assets/images/utils/sort-asc.svg?component'
|
||||
import SortDescIcon from '~/assets/images/utils/sort-desc.svg?component'
|
||||
import WarningIcon from '~/assets/images/utils/issues.svg?component'
|
||||
import ModerationIcon from '~/assets/images/sidebar/admin.svg?component'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import { formatProjectType } from '~/plugins/shorthands.js'
|
||||
|
||||
useHead({
|
||||
title: 'Review projects - Modrinth',
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const now = app.$dayjs()
|
||||
const TIME_24H = 86400000
|
||||
const TIME_48H = TIME_24H * 2
|
||||
|
||||
const { data: projects } = await useAsyncData('moderation/projects?count=1000', () =>
|
||||
useBaseFetch('moderation/projects?count=1000', { internal: true })
|
||||
)
|
||||
const members = ref([])
|
||||
const projectType = ref('all')
|
||||
const oldestFirst = ref(true)
|
||||
|
||||
const projectsFiltered = computed(() =>
|
||||
projects.value.filter(
|
||||
(x) =>
|
||||
projectType.value === 'all' ||
|
||||
app.$getProjectTypeForUrl(x.project_types[0], x.loaders) === projectType.value
|
||||
)
|
||||
)
|
||||
|
||||
const projectsOver24Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H)
|
||||
)
|
||||
const projectsOver48Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_48H)
|
||||
)
|
||||
const projectTypePlural = computed(() =>
|
||||
projectType.value === 'all'
|
||||
? 'projects'
|
||||
: (formatProjectType(projectType.value) + 's').toLowerCase()
|
||||
)
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const set = new Set()
|
||||
set.add('all')
|
||||
|
||||
if (projects.value) {
|
||||
for (const project of projects.value) {
|
||||
set.add(project.inferred_project_type)
|
||||
}
|
||||
}
|
||||
|
||||
return [...set]
|
||||
})
|
||||
|
||||
if (projects.value) {
|
||||
const teamIds = projects.value.map((x) => x.team_id)
|
||||
const organizationIds = projects.value.filter((x) => x.organization).map((x) => x.organization)
|
||||
|
||||
const url = `teams?ids=${encodeURIComponent(JSON.stringify(teamIds))}`
|
||||
const orgUrl = `organizations?ids=${encodeURIComponent(JSON.stringify(organizationIds))}`
|
||||
const { data: result } = await useAsyncData(url, () => useBaseFetch(url))
|
||||
const { data: orgs } = await useAsyncData(orgUrl, () => useBaseFetch(orgUrl, { apiVersion: 3 }))
|
||||
|
||||
if (result.value) {
|
||||
members.value = result.value
|
||||
|
||||
projects.value = projects.value.map((project) => {
|
||||
project.owner = members.value
|
||||
.flat()
|
||||
.find((x) => x.team_id === project.team_id && x.role === 'Owner')
|
||||
project.org = orgs.value.find((x) => x.id === project.organization)
|
||||
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE
|
||||
project.age_warning = ''
|
||||
if (project.age > TIME_24H * 2) {
|
||||
project.age_warning = 'danger'
|
||||
} else if (project.age > TIME_24H) {
|
||||
project.age_warning = 'warning'
|
||||
}
|
||||
project.inferred_project_type = app.$getProjectTypeForUrl(
|
||||
project.project_types[0],
|
||||
project.loaders
|
||||
)
|
||||
return project
|
||||
})
|
||||
}
|
||||
}
|
||||
async function goToProjects() {
|
||||
const project = projectsFiltered.value[0]
|
||||
await router.push({
|
||||
name: 'type-id',
|
||||
params: {
|
||||
type: project.project_types[0],
|
||||
id: project.slug ? project.slug : project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.project {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
@media screen and (min-width: 650px) {
|
||||
display: grid;
|
||||
grid-template: 'title action' 'date action';
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.submitter-info {
|
||||
margin: 0;
|
||||
grid-area: date;
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--color-red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
margin-block: var(--spacing-card-md);
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
grid-area: action;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
display: flex;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.mobile-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.mobile-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.avatar) {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.size-xs {
|
||||
margin-right: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
601
apps/frontend/src/pages/organization/[id].vue
Normal file
601
apps/frontend/src/pages/organization/[id].vue
Normal file
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<div v-if="organization" class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<div v-if="routeHasSettings" class="universal-card">
|
||||
<Breadcrumbs
|
||||
current-title="Settings"
|
||||
:link-stack="[
|
||||
{ href: `/dashboard/organizations`, label: 'Organizations' },
|
||||
{
|
||||
href: `/organization/${organization.slug}`,
|
||||
label: organization.name,
|
||||
allowTrimming: true,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="page-header__settings">
|
||||
<Avatar size="sm" :src="organization.icon_url" />
|
||||
<div class="title-section">
|
||||
<h2 class="settings-title">
|
||||
<nuxt-link :to="`/organization/${organization.slug}/settings`">
|
||||
{{ organization.name }}
|
||||
</nuxt-link>
|
||||
</h2>
|
||||
<span>
|
||||
{{ $formatNumber(acceptedMembers?.length || 0) }}
|
||||
member<template v-if="acceptedMembers?.length !== 1">s</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Organization settings</h2>
|
||||
|
||||
<NavStack>
|
||||
<NavStackItem :link="`/organization/${organization.slug}/settings`" label="Overview">
|
||||
<SettingsIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
:link="`/organization/${organization.slug}/settings/members`"
|
||||
label="Members"
|
||||
>
|
||||
<UsersIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
:link="`/organization/${organization.slug}/settings/projects`"
|
||||
label="Projects"
|
||||
>
|
||||
<BoxIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
:link="`/organization/${organization.slug}/settings/analytics`"
|
||||
label="Analytics"
|
||||
>
|
||||
<ChartIcon />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="universal-card">
|
||||
<div class="page-header__icon">
|
||||
<Avatar size="md" :src="organization.icon_url" />
|
||||
</div>
|
||||
|
||||
<div class="page-header__text">
|
||||
<h1 class="title">{{ organization.name }}</h1>
|
||||
|
||||
<div>
|
||||
<span class="organization-label"><OrganizationIcon /> Organization</span>
|
||||
</div>
|
||||
|
||||
<div class="organization-description">
|
||||
<div class="metadata-item markdown-body collection-description">
|
||||
<p>{{ organization.description }}</p>
|
||||
</div>
|
||||
|
||||
<hr class="card-divider" />
|
||||
|
||||
<div class="primary-stat">
|
||||
<UserIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<span class="primary-stat__counter">
|
||||
{{ $formatNumber(acceptedMembers?.length || 0) }}
|
||||
</span>
|
||||
member<template v-if="acceptedMembers?.length !== 1">s</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="primary-stat">
|
||||
<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="projects?.length !== 1">s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="primary-stat no-margin">
|
||||
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<span class="primary-stat__counter">
|
||||
{{ formatCompactNumber(sumDownloads) }}
|
||||
</span>
|
||||
download<span v-if="sumDownloads !== 1">s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="creator-list universal-card">
|
||||
<div class="title-and-link">
|
||||
<h3>Members</h3>
|
||||
</div>
|
||||
|
||||
<template v-for="member in acceptedMembers" :key="member.user.id">
|
||||
<nuxt-link class="creator button-base" :to="`/user/${member.user.username}`">
|
||||
<Avatar :src="member.user.avatar_url" circle />
|
||||
<p class="name">
|
||||
{{ member.user.username }}
|
||||
<CrownIcon v-if="member.is_owner" v-tooltip="'Organization owner'" />
|
||||
</p>
|
||||
<p class="role">{{ member.role }}</p>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="!routeHasSettings" class="normal-page__content">
|
||||
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
|
||||
<Promotion :external="false" query-param="" />
|
||||
<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
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.slug || project.id"
|
||||
:key="project.id"
|
||||
:name="project.name"
|
||||
:display="cosmetics.searchDisplayMode.user"
|
||||
:featured-image="project.gallery.find((element) => element.featured)?.url"
|
||||
project-type-url="project"
|
||||
:description="project.summary"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:downloads="project.downloads.toString()"
|
||||
:follows="project.followers.toString()"
|
||||
:icon-url="project.icon_url"
|
||||
:categories="project.categories"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:status="
|
||||
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
|
||||
? project.status
|
||||
: null
|
||||
"
|
||||
:type="project.project_types[0] ?? 'project'"
|
||||
: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 {
|
||||
BoxIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
SettingsIcon,
|
||||
ChartIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Breadcrumbs, Promotion } from '@modrinth/ui'
|
||||
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?component'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
|
||||
import OrganizationIcon from '~/assets/images/utils/organization.svg?component'
|
||||
import DownloadIcon from '~/assets/images/utils/download.svg?component'
|
||||
import CrownIcon from '~/assets/images/utils/crown.svg?component'
|
||||
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
const formatCompactNumber = useCompactNumber()
|
||||
|
||||
const auth = await useAuth()
|
||||
const user = await useUser()
|
||||
const cosmetics = useCosmetics()
|
||||
const route = useNativeRoute()
|
||||
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 }),
|
||||
{
|
||||
transform: (projects) => {
|
||||
for (const project of projects) {
|
||||
project.categories = project.categories.concat(project.loaders)
|
||||
|
||||
if (project.mrpack_loaders) {
|
||||
project.categories = project.categories.concat(project.mrpack_loaders)
|
||||
}
|
||||
|
||||
const singleplayer = project.singleplayer && project.singleplayer[0]
|
||||
const clientAndServer = project.client_and_server && project.client_and_server[0]
|
||||
const clientOnly = project.client_only && project.client_only[0]
|
||||
const serverOnly = project.server_only && project.server_only[0]
|
||||
|
||||
// quick and dirty hack to show envs as legacy
|
||||
if (singleplayer && clientAndServer && !clientOnly && !serverOnly) {
|
||||
project.client_side = 'required'
|
||||
project.server_side = 'required'
|
||||
} else if (singleplayer && clientAndServer && clientOnly && !serverOnly) {
|
||||
project.client_side = 'required'
|
||||
project.server_side = 'unsupported'
|
||||
} else if (singleplayer && clientAndServer && !clientOnly && serverOnly) {
|
||||
project.client_side = 'unsupported'
|
||||
project.server_side = 'required'
|
||||
} else if (singleplayer && clientAndServer && clientOnly && serverOnly) {
|
||||
project.client_side = 'optional'
|
||||
project.server_side = 'optional'
|
||||
}
|
||||
}
|
||||
|
||||
return projects
|
||||
},
|
||||
}
|
||||
),
|
||||
])
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([refreshOrganization(), refreshProjects()])
|
||||
}
|
||||
|
||||
if (!organization.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: 'Organization not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Filter accepted, sort by role, then by name and Owner role always goes first
|
||||
const acceptedMembers = computed(() => {
|
||||
const acceptedMembers = organization.value.members?.filter((x) => x.accepted)
|
||||
const owner = acceptedMembers.find((x) => x.is_owner)
|
||||
const rest = acceptedMembers.filter((x) => !x.is_owner) || []
|
||||
|
||||
rest.sort((a, b) => {
|
||||
if (a.role === b.role) {
|
||||
return a.user.username.localeCompare(b.user.username)
|
||||
} else {
|
||||
return a.role.localeCompare(b.role)
|
||||
}
|
||||
})
|
||||
|
||||
return [owner, ...rest]
|
||||
})
|
||||
|
||||
const currentMember = computed(() => {
|
||||
if (auth.value.user && organization.value) {
|
||||
const member = organization.value.members.find((x) => x.user.id === auth.value.user.id)
|
||||
|
||||
if (member) {
|
||||
return member
|
||||
}
|
||||
|
||||
if (tags.value.staffRoles.includes(auth.value.user.role)) {
|
||||
return {
|
||||
user: auth.value.user,
|
||||
role: auth.value.user.role,
|
||||
permissions: auth.value.user.role === 'admin' ? 1023 : 12,
|
||||
accepted: true,
|
||||
payouts_split: 0,
|
||||
avatar_url: auth.value.user.avatar_url,
|
||||
name: auth.value.user.username,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return currentMember.value && (currentMember.value.permissions & EDIT_DETAILS) === EDIT_DETAILS
|
||||
})
|
||||
|
||||
const isInvited = computed(() => {
|
||||
return currentMember.value?.accepted === false
|
||||
})
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const obj = {}
|
||||
|
||||
for (const project of projects.value) {
|
||||
obj[project.project_types[0] ?? 'project'] = true
|
||||
}
|
||||
|
||||
delete obj.project
|
||||
|
||||
return Object.keys(obj)
|
||||
})
|
||||
const sumDownloads = computed(() => {
|
||||
let sum = 0
|
||||
|
||||
for (const project of projects.value) {
|
||||
sum += project.downloads
|
||||
}
|
||||
|
||||
return sum
|
||||
})
|
||||
|
||||
const patchIcon = async (icon) => {
|
||||
const ext = icon.name.split('.').pop()
|
||||
await useBaseFetch(`organization/${organization.value.id}/icon`, {
|
||||
method: 'PATCH',
|
||||
body: icon,
|
||||
query: { ext },
|
||||
apiVersion: 3,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteIcon = async () => {
|
||||
await useBaseFetch(`organization/${organization.value.id}/icon`, {
|
||||
method: 'DELETE',
|
||||
apiVersion: 3,
|
||||
})
|
||||
}
|
||||
|
||||
const patchOrganization = async (id, newData) => {
|
||||
await useBaseFetch(`organization/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: newData,
|
||||
apiVersion: 3,
|
||||
})
|
||||
|
||||
if (newData.slug) {
|
||||
orgId = newData.slug
|
||||
}
|
||||
}
|
||||
|
||||
const onAcceptInvite = useClientTry(async () => {
|
||||
await acceptTeamInvite(organization.value.team_id)
|
||||
await refreshOrganization()
|
||||
})
|
||||
|
||||
const onDeclineInvite = useClientTry(async () => {
|
||||
await removeTeamMember(organization.value.team_id, auth.value?.user.id)
|
||||
await refreshOrganization()
|
||||
})
|
||||
|
||||
provide('organizationContext', {
|
||||
organization,
|
||||
projects,
|
||||
refresh,
|
||||
currentMember,
|
||||
hasPermission,
|
||||
patchIcon,
|
||||
deleteIcon,
|
||||
patchOrganization,
|
||||
})
|
||||
|
||||
const title = `${organization.value.name} - Organization`
|
||||
const description = `${organization.value.description} - View the organization ${organization.value.name} on Modrinth`
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: organization.value.description,
|
||||
ogImage: organization.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header__settings {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: var(--gap-md);
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
margin: 0 !important;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
}
|
||||
|
||||
.page-header__icon {
|
||||
margin-block: 0 !important;
|
||||
}
|
||||
|
||||
.universal-card {
|
||||
h1 {
|
||||
margin-bottom: var(--gap-md);
|
||||
}
|
||||
}
|
||||
|
||||
.creator-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--gap-xl);
|
||||
h3 {
|
||||
margin: 0 0 var(--gap-sm);
|
||||
}
|
||||
.creator {
|
||||
display: grid;
|
||||
gap: var(--gap-xs);
|
||||
background-color: var(--color-raised-bg);
|
||||
padding: var(--gap-sm);
|
||||
margin-left: -0.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
grid-template:
|
||||
'avatar name' auto
|
||||
'avatar role' auto
|
||||
/ auto 1fr;
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.name {
|
||||
grid-area: name;
|
||||
align-self: flex-end;
|
||||
margin-left: var(--gap-xs);
|
||||
font-weight: bold;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
svg {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
}
|
||||
|
||||
.role {
|
||||
grid-area: role;
|
||||
align-self: flex-start;
|
||||
margin-left: var(--gap-xs);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-stat {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.secondary-stat__icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.secondary-stat__text {
|
||||
margin-left: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.organization-label {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.organization-description {
|
||||
margin-top: var(--spacing-card-sm);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.title-and-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
}
|
||||
.project-overview {
|
||||
gap: var(--gap-md);
|
||||
padding: var(--gap-xl);
|
||||
.project-card {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
:deep(.title) {
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.popout-heading {
|
||||
padding: var(--gap-sm) var(--gap-md);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.popout-checkbox {
|
||||
padding: var(--gap-sm) var(--gap-md);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="normal-page__content">
|
||||
<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.map((x) => ({ title: x.name, ...x }))" />
|
||||
</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>
|
||||
219
apps/frontend/src/pages/organization/[id]/settings/index.vue
Normal file
219
apps/frontend/src/pages/organization/[id]/settings/index.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { Button, FileInput, Avatar, ConfirmModal } from '@modrinth/ui'
|
||||
import { UploadIcon, SaveIcon, TrashIcon } from '@modrinth/assets'
|
||||
|
||||
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>
|
||||
431
apps/frontend/src/pages/organization/[id]/settings/members.vue
Normal file
431
apps/frontend/src/pages/organization/[id]/settings/members.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<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, 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.is_owner"
|
||||
@click="() => onLeaveProject(organization.team_id, auth.user.id)"
|
||||
>
|
||||
<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>
|
||||
<CrownIcon v-if="member.is_owner" v-tooltip="'Organization owner'" />
|
||||
</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 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"
|
||||
: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>
|
||||
<template v-if="!member.is_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.is_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.is_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.is_owner && currentMember.is_owner && member.accepted"
|
||||
@click="() => onTransferOwnership(organization.team_id, member.user.id)"
|
||||
>
|
||||
<TransferIcon />
|
||||
Transfer ownership
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
SaveIcon,
|
||||
TransferIcon,
|
||||
UserPlusIcon,
|
||||
UserXIcon as UserRemoveIcon,
|
||||
DropdownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Badge, Avatar, Checkbox } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import CrownIcon from '~/assets/images/utils/crown.svg?component'
|
||||
|
||||
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 allTeamMembers = ref(organization.value.members)
|
||||
|
||||
watch(
|
||||
() => organization.value,
|
||||
() => {
|
||||
allTeamMembers.value = organization.value.members
|
||||
}
|
||||
)
|
||||
|
||||
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 (teamId, uid) => {
|
||||
await removeTeamMember(teamId, uid)
|
||||
await navigateTo(`/organization/${organization.value.id}`)
|
||||
}
|
||||
|
||||
const onLeaveProject = useClientTry(leaveProject)
|
||||
|
||||
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.is_owner
|
||||
? {
|
||||
permissions: member.permissions,
|
||||
organization_permissions: member.organization_permissions,
|
||||
role: member.role,
|
||||
payouts_split: member.payouts_split,
|
||||
}
|
||||
: {
|
||||
payouts_split: member.payouts_split,
|
||||
role: member.role,
|
||||
}
|
||||
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;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
svg {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
694
apps/frontend/src/pages/organization/[id]/settings/projects.vue
Normal file
694
apps/frontend/src/pages/organization/[id]/settings/projects.vue
Normal file
@@ -0,0 +1,694 @@
|
||||
<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 />
|
||||
{{ formatMessage(commonMessages.createAProjectButton) }}
|
||||
</Button>
|
||||
<OrganizationProjectTransferModal
|
||||
:projects="usersOwnedProjects || []"
|
||||
@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 {
|
||||
BoxIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
IssuesIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
SortAscendingIcon,
|
||||
SortDescendingIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Modal, Avatar, CopyCode, Badge, Checkbox } from '@modrinth/ui'
|
||||
|
||||
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
||||
import OrganizationProjectTransferModal from '~/components/ui/OrganizationProjectTransferModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { organization, projects, refresh } = inject('organizationContext')
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: userProjects, refresh: refreshUserProjects } = await useAsyncData(
|
||||
`user/${auth.value.user.id}/projects`,
|
||||
() => useBaseFetch(`user/${auth.value.user.id}/projects`),
|
||||
{
|
||||
watch: [auth],
|
||||
}
|
||||
)
|
||||
|
||||
const usersOwnedProjects = ref([])
|
||||
|
||||
watch(
|
||||
() => userProjects.value,
|
||||
async () => {
|
||||
if (!userProjects.value) return
|
||||
if (!userProjects.value.length) return
|
||||
|
||||
const projects = userProjects.value.filter((project) => project.organization === null)
|
||||
|
||||
const teamIds = projects.map((project) => project?.team).filter((x) => x)
|
||||
// Shape of teams is member[][]
|
||||
const teams = await useBaseFetch(`teams?ids=${JSON.stringify(teamIds)}`, {
|
||||
apiVersion: 3,
|
||||
})
|
||||
// for each team id, figure out if the user is a member, and is_owner. Then filter the projects to only include those that are owned by the user
|
||||
const ownedTeamIds = teamIds.filter((_tid, i) => {
|
||||
const team = teams?.[i]
|
||||
if (!team) return false
|
||||
const member = team.find((member) => member.user.id === auth.value.user.id)
|
||||
return member && member.is_owner
|
||||
})
|
||||
const ownedProjects = projects.filter((project) => ownedTeamIds.includes(project.team))
|
||||
usersOwnedProjects.value = ownedProjects
|
||||
}, // watch options
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
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()
|
||||
await refreshUserProjects()
|
||||
|
||||
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-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-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>
|
||||
294
apps/frontend/src/pages/report.vue
Normal file
294
apps/frontend/src/pages/report.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Card>
|
||||
<div class="content">
|
||||
<div>
|
||||
<h1 class="card-title-adjustments">Submit a Report</h1>
|
||||
<div>
|
||||
<p>
|
||||
Modding should be safe for everyone, so we take abuse and malicious intent seriously
|
||||
at Modrinth. If you encounter content that violates our
|
||||
<nuxt-link class="text-link" to="/legal/terms">Terms of Service</nuxt-link> or our
|
||||
<nuxt-link class="text-link" to="/legal/rules">Rules</nuxt-link>, please report it to
|
||||
us here.
|
||||
</p>
|
||||
<p>
|
||||
This form is intended exclusively for reporting abuse or harmful content to Modrinth
|
||||
staff. For bugs related to specific projects, please use the project's designated
|
||||
Issues link or Discord channel.
|
||||
</p>
|
||||
<p>
|
||||
Your privacy is important to us; rest assured that your identifying information will
|
||||
be kept confidential.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-info-section">
|
||||
<div class="report-info-item">
|
||||
<label for="report-item">Item type to report</label>
|
||||
<DropdownSelect
|
||||
id="report-item"
|
||||
v-model="reportItem"
|
||||
name="report-item"
|
||||
:options="reportItems"
|
||||
:display-name="capitalizeString"
|
||||
:multiple="false"
|
||||
:searchable="false"
|
||||
:show-no-results="false"
|
||||
:show-labels="false"
|
||||
placeholder="Choose report item"
|
||||
/>
|
||||
</div>
|
||||
<div class="report-info-item">
|
||||
<label for="report-item-id">Item ID</label>
|
||||
<input
|
||||
id="report-item-id"
|
||||
v-model="reportItemID"
|
||||
type="text"
|
||||
placeholder="ex. project ID"
|
||||
autocomplete="off"
|
||||
:disabled="reportItem === ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="report-info-item">
|
||||
<label for="report-type">Reason for report</label>
|
||||
<DropdownSelect
|
||||
id="report-type"
|
||||
v-model="reportType"
|
||||
name="report-type"
|
||||
:options="reportTypes"
|
||||
:multiple="false"
|
||||
:searchable="false"
|
||||
:show-no-results="false"
|
||||
:show-labels="false"
|
||||
:display-name="capitalizeString"
|
||||
placeholder="Choose report type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-submission-section">
|
||||
<div>
|
||||
<p>
|
||||
Please provide additional context about your report. Include links and images if
|
||||
possible. <strong>Empty reports will be closed.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<MarkdownEditor v-model="reportBody" placeholder="" :on-image-upload="onImageUpload" />
|
||||
</div>
|
||||
<div class="submit-button">
|
||||
<Button
|
||||
id="submit-button"
|
||||
color="primary"
|
||||
:disabled="submitLoading || !canSubmit"
|
||||
@click="submitReport"
|
||||
>
|
||||
<SaveIcon />
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, Button, MarkdownEditor, DropdownSelect } from '@modrinth/ui'
|
||||
import { SaveIcon } from '@modrinth/assets'
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
|
||||
const tags = useTags()
|
||||
const route = useNativeRoute()
|
||||
|
||||
const accessQuery = (id: string): string => {
|
||||
return route.query?.[id]?.toString() || ''
|
||||
}
|
||||
|
||||
const submitLoading = ref<boolean>(false)
|
||||
|
||||
const uploadedImageIDs = ref<string[]>([])
|
||||
|
||||
const reportBody = ref<string>(accessQuery('body'))
|
||||
const reportItem = ref<string>(accessQuery('item'))
|
||||
const reportItemID = ref<string>(accessQuery('itemID'))
|
||||
const reportType = ref<string>('')
|
||||
|
||||
const reportItems = ['project', 'version', 'user']
|
||||
const reportTypes = computed(() => tags.value.reportTypes)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return (
|
||||
reportItem.value !== '' &&
|
||||
reportItemID.value !== '' &&
|
||||
reportType.value !== '' &&
|
||||
reportBody.value !== ''
|
||||
)
|
||||
})
|
||||
|
||||
const submissionValidation = () => {
|
||||
if (!canSubmit.value) {
|
||||
throw new Error('Please fill out all required fields')
|
||||
}
|
||||
|
||||
if (reportItem.value === '') {
|
||||
throw new Error('Please select a report item')
|
||||
}
|
||||
|
||||
if (reportItemID.value === '') {
|
||||
throw new Error('Please enter a report item ID')
|
||||
}
|
||||
|
||||
if (reportType.value === '') {
|
||||
throw new Error('Please select a report type')
|
||||
}
|
||||
|
||||
if (reportBody.value === '') {
|
||||
throw new Error('Please enter a report body')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const capitalizeString = (value?: string) => {
|
||||
if (!value) return ''
|
||||
return value?.charAt(0).toUpperCase() + value?.slice(1)
|
||||
}
|
||||
|
||||
const submitReport = async () => {
|
||||
submitLoading.value = true
|
||||
|
||||
let data: {
|
||||
[key: string]: unknown
|
||||
} = {
|
||||
report_type: reportType.value,
|
||||
item_type: reportItem.value,
|
||||
item_id: reportItemID.value,
|
||||
body: reportBody.value,
|
||||
}
|
||||
|
||||
function takeNLast<T>(arr: T[], n: number): T[] {
|
||||
return arr.slice(Math.max(arr.length - n, 0))
|
||||
}
|
||||
|
||||
if (uploadedImageIDs.value.length > 0) {
|
||||
data = {
|
||||
...data,
|
||||
uploaded_images: takeNLast(uploadedImageIDs.value, 10),
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
submissionValidation()
|
||||
} catch (error) {
|
||||
submitLoading.value = false
|
||||
|
||||
if (error instanceof Error) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: error.message,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await useBaseFetch('report', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})) as { id: string }
|
||||
|
||||
submitLoading.value = false
|
||||
|
||||
if (response?.id) {
|
||||
navigateTo(`/dashboard/report/${response.id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
submitLoading.value = false
|
||||
|
||||
if (error instanceof Error) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: error.message,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const onImageUpload = async (file: File) => {
|
||||
const item = await useImageUpload(file, { context: 'report' })
|
||||
uploadedImageIDs.value.push(item.id)
|
||||
return item.url
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.submit-button {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.card-title-adjustments {
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 0.5rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
// TODO: Get rid of this hack when removing global styles from the website.
|
||||
// Overflow decides the behavior of md editor but also clips the border.
|
||||
// In the future, we should use ring instead of block-shadow for the
|
||||
// green ring around the md editor
|
||||
padding-inline: var(--gap-md);
|
||||
padding-bottom: var(--gap-md);
|
||||
margin-inline: calc(var(--gap-md) * -1);
|
||||
|
||||
display: grid;
|
||||
|
||||
// Disable horizontal stretch
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-info-section {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
gap: var(--gap-md);
|
||||
|
||||
:global(.animated-dropdown) {
|
||||
& > .selected {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.report-info-item {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--gap-sm);
|
||||
color: var(--color-text-dark);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1007
apps/frontend/src/pages/search/[searchProjectType].vue
Normal file
1007
apps/frontend/src/pages/search/[searchProjectType].vue
Normal file
File diff suppressed because it is too large
Load Diff
96
apps/frontend/src/pages/settings.vue
Normal file
96
apps/frontend/src/pages/settings.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1>{{ formatMessage(commonMessages.settingsLabel) }}</h1>
|
||||
</div>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<NavStack>
|
||||
<h3>Display</h3>
|
||||
<NavStackItem
|
||||
link="/settings"
|
||||
:label="formatMessage(commonSettingsMessages.appearance)"
|
||||
>
|
||||
<PaintBrushIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
v-if="isStaging"
|
||||
link="/settings/language"
|
||||
:label="formatMessage(commonSettingsMessages.language)"
|
||||
>
|
||||
<LanguagesIcon />
|
||||
</NavStackItem>
|
||||
<template v-if="auth.user">
|
||||
<h3>Account</h3>
|
||||
<NavStackItem
|
||||
link="/settings/profile"
|
||||
:label="formatMessage(commonSettingsMessages.profile)"
|
||||
>
|
||||
<UserIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
link="/settings/account"
|
||||
:label="formatMessage(commonSettingsMessages.account)"
|
||||
>
|
||||
<ShieldIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
link="/settings/authorizations"
|
||||
:label="formatMessage(commonSettingsMessages.authorizedApps)"
|
||||
>
|
||||
<GridIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
link="/settings/sessions"
|
||||
:label="formatMessage(commonSettingsMessages.sessions)"
|
||||
>
|
||||
<MonitorSmartphoneIcon />
|
||||
</NavStackItem>
|
||||
</template>
|
||||
<template v-if="auth.user">
|
||||
<h3>Developer</h3>
|
||||
<NavStackItem
|
||||
link="/settings/pats"
|
||||
:label="formatMessage(commonSettingsMessages.pats)"
|
||||
>
|
||||
<KeyIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
link="/settings/applications"
|
||||
:label="formatMessage(commonSettingsMessages.applications)"
|
||||
>
|
||||
<ServerIcon />
|
||||
</NavStackItem>
|
||||
</template>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<NuxtPage :route="route" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ServerIcon,
|
||||
GridIcon,
|
||||
PaintBrushIcon,
|
||||
UserIcon,
|
||||
ShieldIcon,
|
||||
KeyIcon,
|
||||
LanguagesIcon,
|
||||
} from '@modrinth/assets'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
import MonitorSmartphoneIcon from '~/assets/images/utils/monitor-smartphone.svg?component'
|
||||
|
||||
import { commonMessages, commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const route = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
const isStaging = useRuntimeConfig().public.siteUrl !== 'https://modrinth.com'
|
||||
</script>
|
||||
639
apps/frontend/src/pages/settings/account.vue
Normal file
639
apps/frontend/src/pages/settings/account.vue
Normal file
@@ -0,0 +1,639 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete your account?"
|
||||
description="This will **immediately delete all of your user data and follows**. This will not delete your projects. Deleting your account cannot be reversed.<br><br>If you need help with your account, get support on the [Modrinth Discord](https://discord.modrinth.com)."
|
||||
proceed-label="Delete this account"
|
||||
:confirmation-text="auth.user.username"
|
||||
:has-to-type="true"
|
||||
@proceed="deleteAccount"
|
||||
/>
|
||||
<Modal ref="changeEmailModal" :header="`${auth.user.email ? 'Change' : 'Add'} email`">
|
||||
<div class="universal-modal">
|
||||
<p>Your account information is not displayed publicly.</p>
|
||||
<label for="email-input"><span class="label__title">Email address</span> </label>
|
||||
<input
|
||||
id="email-input"
|
||||
v-model="email"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="`Enter your email address...`"
|
||||
@keyup.enter="saveEmail()"
|
||||
/>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.changeEmailModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!email"
|
||||
@click="saveEmail()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
ref="managePasswordModal"
|
||||
:header="`${
|
||||
removePasswordMode ? 'Remove' : auth.user.has_password ? 'Change' : 'Add'
|
||||
} password`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<ul
|
||||
v-if="newPassword !== confirmNewPassword && confirmNewPassword.length > 0"
|
||||
class="known-errors"
|
||||
>
|
||||
<li>Input passwords do not match!</li>
|
||||
</ul>
|
||||
<label v-if="removePasswordMode" for="old-password">
|
||||
<span class="label__title">Confirm password</span>
|
||||
<span class="label__description">Please enter your password to proceed.</span>
|
||||
</label>
|
||||
<label v-else-if="auth.user.has_password" for="old-password">
|
||||
<span class="label__title">Old password</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="auth.user.has_password"
|
||||
id="old-password"
|
||||
v-model="oldPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="`${removePasswordMode ? 'Confirm' : 'Old'} password`"
|
||||
/>
|
||||
<template v-if="!removePasswordMode">
|
||||
<label for="new-password"><span class="label__title">New password</span></label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
<label for="confirm-new-password"
|
||||
><span class="label__title">Confirm new password</span></label
|
||||
>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</template>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.managePasswordModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<template v-if="removePasswordMode">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
:disabled="!oldPassword"
|
||||
@click="savePassword"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove password
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="auth.user.has_password && auth.user.auth_providers.length > 0"
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
@click="removePasswordMode = true"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="
|
||||
newPassword.length == 0 ||
|
||||
(auth.user.has_password && oldPassword.length == 0) ||
|
||||
newPassword !== confirmNewPassword
|
||||
"
|
||||
@click="savePassword"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save password
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
ref="manageTwoFactorModal"
|
||||
:header="`${
|
||||
auth.user.has_totp && twoFactorStep === 0 ? 'Remove' : 'Setup'
|
||||
} two-factor authentication`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<template v-if="auth.user.has_totp && twoFactorStep === 0">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">Enter two-factor code</span>
|
||||
<span class="label__description">Please enter a two-factor code to proceed.</span>
|
||||
</label>
|
||||
<input
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCode"
|
||||
maxlength="11"
|
||||
type="text"
|
||||
placeholder="Enter code..."
|
||||
@keyup.enter="removeTwoFactor()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.manageTwoFactorModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button danger-button" @click="removeTwoFactor">
|
||||
<TrashIcon />
|
||||
Remove 2FA
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="twoFactorStep === 0">
|
||||
<p>
|
||||
Two-factor authentication keeps your account secure by requiring access to a second
|
||||
device in order to sign in.
|
||||
<br /><br />
|
||||
Scan the QR code with <a href="https://authy.com/">Authy</a>,
|
||||
<a href="https://www.microsoft.com/en-us/security/mobile-authenticator-app">
|
||||
Microsoft Authenticator</a
|
||||
>, or any other 2FA app to begin.
|
||||
</p>
|
||||
<qrcode-vue
|
||||
v-if="twoFactorSecret"
|
||||
:value="`otpauth://totp/${encodeURIComponent(
|
||||
auth.user.email
|
||||
)}?secret=${twoFactorSecret}&issuer=Modrinth`"
|
||||
:size="250"
|
||||
:margin="2"
|
||||
level="H"
|
||||
/>
|
||||
<p>
|
||||
If the QR code does not scan, you can manually enter the secret:
|
||||
<strong>{{ twoFactorSecret }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 1">
|
||||
<label for="verify-code">
|
||||
<span class="label__title">Verify code</span>
|
||||
<span class="label__description"
|
||||
>Enter the one-time code from authenticator to verify access.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="verify-code"
|
||||
v-model="twoFactorCode"
|
||||
maxlength="6"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="Enter code..."
|
||||
@keyup.enter="verifyTwoFactorCode()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 2">
|
||||
<p>
|
||||
Download and save these back-up codes in a safe place. You can use these in-place of a
|
||||
2FA code if you ever lose access to your device! You should protect these codes like
|
||||
your password.
|
||||
</p>
|
||||
<p>Backup codes can only be used once.</p>
|
||||
<ul>
|
||||
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div class="input-group push-right">
|
||||
<button v-if="twoFactorStep === 1" class="iconified-button" @click="twoFactorStep = 0">
|
||||
<LeftArrowIcon />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep !== 2"
|
||||
class="iconified-button"
|
||||
@click="$refs.manageTwoFactorModal.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep <= 1"
|
||||
class="iconified-button brand-button"
|
||||
@click="twoFactorStep === 1 ? verifyTwoFactorCode() : (twoFactorStep = 1)"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep === 2"
|
||||
class="iconified-button brand-button"
|
||||
@click="$refs.manageTwoFactorModal.hide()"
|
||||
>
|
||||
<CheckIcon />
|
||||
Complete setup
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal ref="manageProvidersModal" header="Authentication providers">
|
||||
<div class="universal-modal">
|
||||
<div class="table">
|
||||
<div class="table-row table-head">
|
||||
<div class="table-cell table-text">Provider</div>
|
||||
<div class="table-cell table-text">Actions</div>
|
||||
</div>
|
||||
<div v-for="provider in authProviders" :key="provider.id" class="table-row">
|
||||
<div class="table-cell table-text">
|
||||
<span><component :is="provider.icon" /> {{ provider.display }}</span>
|
||||
</div>
|
||||
<div class="table-cell table-text manage">
|
||||
<button
|
||||
v-if="auth.user.auth_providers.includes(provider.id)"
|
||||
class="btn"
|
||||
@click="removeAuthProvider(provider.id)"
|
||||
>
|
||||
<TrashIcon /> Remove
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
class="btn"
|
||||
:href="`${getAuthUrl(provider.id, '/settings/account')}&token=${auth.token}`"
|
||||
>
|
||||
<ExternalIcon /> Add
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.manageProvidersModal.hide()">
|
||||
<XIcon />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<section class="universal-card">
|
||||
<h2>Account security</h2>
|
||||
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Email</span>
|
||||
<span class="label__description">Changes the email associated with your account.</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="$refs.changeEmailModal.show()">
|
||||
<template v-if="auth.user.email">
|
||||
<EditIcon />
|
||||
Change email
|
||||
</template>
|
||||
<template v-else>
|
||||
<PlusIcon />
|
||||
Add email
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Password</span>
|
||||
<span v-if="auth.user.has_password" class="label__description">
|
||||
Change <template v-if="auth.user.auth_providers.length > 0">or remove</template> the
|
||||
password used to login to your account.
|
||||
</span>
|
||||
<span v-else class="label__description">
|
||||
Set a permanent password to login to your account.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
class="iconified-button"
|
||||
@click="
|
||||
() => {
|
||||
oldPassword = ''
|
||||
newPassword = ''
|
||||
confirmNewPassword = ''
|
||||
removePasswordMode = false
|
||||
$refs.managePasswordModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<KeyIcon />
|
||||
<template v-if="auth.user.has_password"> Change password </template>
|
||||
<template v-else> Add password </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Two-factor authentication</span>
|
||||
<span class="label__description">
|
||||
Add an additional layer of security to your account during login.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="showTwoFactorModal">
|
||||
<template v-if="auth.user.has_totp"> <TrashIcon /> Remove 2FA </template>
|
||||
<template v-else> <PlusIcon /> Setup 2FA </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Manage authentication providers</span>
|
||||
<span class="label__description">
|
||||
Add or remove sign-on methods from your account, including GitHub, GitLab, Microsoft,
|
||||
Discord, Steam, and Google.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="$refs.manageProvidersModal.show()">
|
||||
<SettingsIcon /> Manage providers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="delete-account" class="universal-card">
|
||||
<h2>Delete account</h2>
|
||||
<p>
|
||||
Once you delete your account, there is no going back. Deleting your account will remove all
|
||||
attached data, excluding projects, from our servers.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
@click="$refs.modal_confirm.show()"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete account
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
CheckIcon,
|
||||
ExternalIcon,
|
||||
} from '@modrinth/assets'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import GitHubIcon from 'assets/icons/auth/sso-github.svg'
|
||||
import MicrosoftIcon from 'assets/icons/auth/sso-microsoft.svg'
|
||||
import GoogleIcon from 'assets/icons/auth/sso-google.svg'
|
||||
import SteamIcon from 'assets/icons/auth/sso-steam.svg'
|
||||
import DiscordIcon from 'assets/icons/auth/sso-discord.svg'
|
||||
import KeyIcon from 'assets/icons/auth/key.svg'
|
||||
import GitLabIcon from 'assets/icons/auth/sso-gitlab.svg'
|
||||
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Account settings - Modrinth',
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const auth = await useAuth()
|
||||
|
||||
const changeEmailModal = ref()
|
||||
const email = ref(auth.value.user.email)
|
||||
async function saveEmail() {
|
||||
if (!email.value) {
|
||||
return
|
||||
}
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`auth/email`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
email: email.value,
|
||||
},
|
||||
})
|
||||
changeEmailModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const managePasswordModal = ref()
|
||||
const removePasswordMode = ref(false)
|
||||
const oldPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmNewPassword = ref('')
|
||||
async function savePassword() {
|
||||
if (newPassword.value !== confirmNewPassword.value) {
|
||||
return
|
||||
}
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`auth/password`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
old_password: auth.value.user.has_password ? oldPassword.value : null,
|
||||
new_password: removePasswordMode.value ? null : newPassword.value,
|
||||
},
|
||||
})
|
||||
managePasswordModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const manageTwoFactorModal = ref()
|
||||
const twoFactorSecret = ref(null)
|
||||
const twoFactorFlow = ref(null)
|
||||
const twoFactorStep = ref(0)
|
||||
async function showTwoFactorModal() {
|
||||
twoFactorStep.value = 0
|
||||
twoFactorCode.value = null
|
||||
twoFactorIncorrect.value = false
|
||||
if (auth.value.user.has_totp) {
|
||||
manageTwoFactorModal.value.show()
|
||||
return
|
||||
}
|
||||
|
||||
twoFactorSecret.value = null
|
||||
twoFactorFlow.value = null
|
||||
backupCodes.value = []
|
||||
manageTwoFactorModal.value.show()
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/2fa/get_secret', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
twoFactorSecret.value = res.secret
|
||||
twoFactorFlow.value = res.flow
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const twoFactorIncorrect = ref(false)
|
||||
const twoFactorCode = ref(null)
|
||||
const backupCodes = ref([])
|
||||
async function verifyTwoFactorCode() {
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/2fa', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: twoFactorCode.value ? twoFactorCode.value : '',
|
||||
flow: twoFactorFlow.value,
|
||||
},
|
||||
})
|
||||
|
||||
backupCodes.value = res.backup_codes
|
||||
twoFactorStep.value = 2
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
twoFactorIncorrect.value = true
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removeTwoFactor() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch('auth/2fa', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
code: twoFactorCode.value ? twoFactorCode.value.toString() : '',
|
||||
},
|
||||
})
|
||||
manageTwoFactorModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
twoFactorIncorrect.value = true
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const authProviders = [
|
||||
{
|
||||
id: 'github',
|
||||
display: 'GitHub',
|
||||
icon: GitHubIcon,
|
||||
},
|
||||
{
|
||||
id: 'gitlab',
|
||||
display: 'GitLab',
|
||||
icon: GitLabIcon,
|
||||
},
|
||||
{
|
||||
id: 'steam',
|
||||
display: 'Steam',
|
||||
icon: SteamIcon,
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
display: 'Discord',
|
||||
icon: DiscordIcon,
|
||||
},
|
||||
{
|
||||
id: 'microsoft',
|
||||
display: 'Microsoft',
|
||||
icon: MicrosoftIcon,
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
display: 'Google',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
]
|
||||
|
||||
async function deleteAccount() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
useCookie('auth-token').value = null
|
||||
window.location.href = '/'
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
canvas {
|
||||
margin: 0 auto;
|
||||
border-radius: var(--size-rounded-card);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
grid-template-columns: 1fr 10rem;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto 0;
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
568
apps/frontend/src/pages/settings/applications.vue
Normal file
568
apps/frontend/src/pages/settings/applications.vue
Normal file
@@ -0,0 +1,568 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete this application?"
|
||||
description="This will permanently delete this application and revoke all access tokens. (forever!)"
|
||||
proceed-label="Delete this application"
|
||||
@proceed="removeApp(editingId)"
|
||||
/>
|
||||
<Modal ref="appModal" header="Application information">
|
||||
<div class="universal-modal">
|
||||
<label for="app-name"><span class="label__title">Name</span> </label>
|
||||
<input
|
||||
id="app-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's name..."
|
||||
/>
|
||||
<label v-if="editingId" for="app-icon"><span class="label__title">Icon</span> </label>
|
||||
<div v-if="editingId" class="icon-submission">
|
||||
<Avatar size="md" :src="icon" />
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
class="btn"
|
||||
prompt="Upload icon"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="onImageSelection"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
</div>
|
||||
<label v-if="editingId" for="app-url">
|
||||
<span class="label__title">URL</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="editingId"
|
||||
id="app-url"
|
||||
v-model="url"
|
||||
maxlength="255"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<label v-if="editingId" for="app-description">
|
||||
<span class="label__title">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="editingId"
|
||||
id="app-description"
|
||||
v-model="description"
|
||||
class="description-textarea"
|
||||
maxlength="255"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's description..."
|
||||
/>
|
||||
<label for="app-scopes"><span class="label__title">Scopes</span> </label>
|
||||
<div id="app-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="scope in scopeList"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="() => (scopesVal = toggleScope(scopesVal, scope))"
|
||||
/>
|
||||
</div>
|
||||
<label for="app-redirect-uris"><span class="label__title">Redirect uris</span> </label>
|
||||
<div class="uri-input-list">
|
||||
<div v-for="(_, index) in redirectUris" :key="index">
|
||||
<div class="input-group url-input-group-fixes">
|
||||
<input
|
||||
v-model="redirectUris[index]"
|
||||
maxlength="2048"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com/auth/callback"
|
||||
/>
|
||||
<Button v-if="index !== 0" icon-only @click="() => redirectUris.splice(index, 1)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="index === 0"
|
||||
color="primary"
|
||||
icon-only
|
||||
@click="() => redirectUris.push('')"
|
||||
>
|
||||
<PlusIcon /> Add more
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="redirectUris.length <= 0">
|
||||
<Button color="primary" icon-only @click="() => redirectUris.push('')">
|
||||
<PlusIcon /> Add a redirect uri
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-row input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.appModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="editingId"
|
||||
:disabled="!canSubmit"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="editApp"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="!canSubmit"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="createApp"
|
||||
>
|
||||
<PlusIcon />
|
||||
Create App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2>{{ formatMessage(commonSettingsMessages.applications) }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
() => {
|
||||
name = null
|
||||
icon = null
|
||||
scopesVal = 0
|
||||
redirectUris = ['']
|
||||
editingId = null
|
||||
expires = null
|
||||
$refs.appModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> New Application
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Applications can be used to authenticate Modrinth's users with your products. For more
|
||||
information, see
|
||||
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>.
|
||||
</p>
|
||||
<div v-for="app in usersApps" :key="app.id" class="universal-card recessed token">
|
||||
<div class="token-info">
|
||||
<div class="token-icon">
|
||||
<Avatar size="sm" :src="app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">{{ app.name }}</h2>
|
||||
<div>Created on {{ new Date(app.created).toLocaleDateString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="token-information">
|
||||
<span class="label__title">About</span>
|
||||
</label>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
Client ID
|
||||
<CopyCode :text="app.id" />
|
||||
</div>
|
||||
<div v-if="!!clientCreatedInState(app.id)">
|
||||
<div>
|
||||
Client Secret <CopyCode :text="clientCreatedInState(app.id)?.client_secret" />
|
||||
</div>
|
||||
<div class="secret_disclaimer">
|
||||
<i> Save your secret now, it will be hidden after you leave this page! </i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<Button
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
setForm({
|
||||
...app,
|
||||
redirect_uris: app.redirect_uris.map((u) => u.uri) || [],
|
||||
})
|
||||
$refs.appModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
editingId = app.id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { UploadIcon, PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from '@modrinth/assets'
|
||||
import { CopyCode, ConfirmModal, Button, Checkbox, Avatar, FileInput } from '@modrinth/ui'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
import {
|
||||
scopeList,
|
||||
hasScope,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
getScopeValue,
|
||||
} from '~/composables/auth/scopes.ts'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Applications - Modrinth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { scopesToLabels } = useScopes()
|
||||
|
||||
const appModal = ref()
|
||||
|
||||
// Any apps created in the current state will be stored here
|
||||
// Users can copy Client Secrets and such before the page reloads
|
||||
const createdApps = ref([])
|
||||
|
||||
const editingId = ref(null)
|
||||
const name = ref(null)
|
||||
const icon = ref(null)
|
||||
const scopesVal = ref(BigInt(0))
|
||||
const redirectUris = ref([''])
|
||||
const url = ref(null)
|
||||
const description = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: usersApps, refresh } = await useAsyncData(
|
||||
'usersApps',
|
||||
() =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/oauth_apps`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
{
|
||||
watch: [auth],
|
||||
}
|
||||
)
|
||||
|
||||
const setForm = (app) => {
|
||||
if (app?.id) {
|
||||
editingId.value = app.id
|
||||
} else {
|
||||
editingId.value = null
|
||||
}
|
||||
name.value = app?.name || ''
|
||||
icon.value = app?.icon_url || ''
|
||||
scopesVal.value = app?.max_scopes || BigInt(0)
|
||||
url.value = app?.url || ''
|
||||
description.value = app?.description || ''
|
||||
|
||||
if (app?.redirect_uris) {
|
||||
redirectUris.value = app.redirect_uris.map((uri) => uri?.uri || uri)
|
||||
} else {
|
||||
redirectUris.value = ['']
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
// Make sure name, scopes, and return uri are at least filled in
|
||||
const filledIn =
|
||||
name.value && name.value !== '' && name.value?.length > 2 && redirectUris.value.length > 0
|
||||
// Make sure the redirect uris are either one empty string or all filled in with valid urls
|
||||
const oneValid = redirectUris.value.length === 1 && redirectUris.value[0] === ''
|
||||
let allValid
|
||||
try {
|
||||
allValid = redirectUris.value.every((uri) => {
|
||||
const url = new URL(uri)
|
||||
return !!url
|
||||
})
|
||||
} catch (err) {
|
||||
allValid = false
|
||||
}
|
||||
return filledIn && (oneValid || allValid)
|
||||
})
|
||||
|
||||
const clientCreatedInState = (id) => {
|
||||
return createdApps.value.find((app) => app.id === id)
|
||||
}
|
||||
|
||||
async function onImageSelection(files) {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
const extFromType = file.type.split('/')[1]
|
||||
|
||||
await useBaseFetch('oauth/app/' + editingId.value + '/icon', {
|
||||
method: 'PATCH',
|
||||
internal: true,
|
||||
body: file,
|
||||
query: {
|
||||
ext: extFromType,
|
||||
},
|
||||
})
|
||||
|
||||
await refresh()
|
||||
|
||||
const app = usersApps.value.find((app) => app.id === editingId.value)
|
||||
if (app) {
|
||||
setForm(app)
|
||||
}
|
||||
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'Icon updated',
|
||||
text: 'Your application icon has been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
const createdAppInfo = await useBaseFetch('oauth/app', {
|
||||
method: 'POST',
|
||||
internal: true,
|
||||
body: {
|
||||
name: name.value,
|
||||
icon_url: icon.value,
|
||||
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
|
||||
redirect_uris: redirectUris.value,
|
||||
},
|
||||
})
|
||||
|
||||
createdApps.value.push(createdAppInfo)
|
||||
|
||||
setForm(null)
|
||||
appModal.value.hide()
|
||||
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function editApp() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
|
||||
// check if there's any difference between the current app and the one in the state
|
||||
const app = usersApps.value.find((app) => app.id === editingId.value)
|
||||
if (!app) {
|
||||
throw new Error('No app found')
|
||||
}
|
||||
|
||||
if (
|
||||
app.name === name.value &&
|
||||
app.icon_url === icon.value &&
|
||||
app.max_scopes === scopesVal.value &&
|
||||
app.redirect_uris === redirectUris.value &&
|
||||
app.url === url.value &&
|
||||
app.description === description.value
|
||||
) {
|
||||
setForm(null)
|
||||
editingId.value = null
|
||||
appModal.value.hide()
|
||||
throw new Error('No changes detected')
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: name.value,
|
||||
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
|
||||
redirect_uris: redirectUris.value,
|
||||
}
|
||||
|
||||
if (url.value && url.value?.length > 0) {
|
||||
body.url = url.value
|
||||
}
|
||||
|
||||
if (description.value && description.value?.length > 0) {
|
||||
body.description = description.value
|
||||
}
|
||||
|
||||
if (icon.value && icon.value?.length > 0) {
|
||||
body.icon_url = icon.value
|
||||
}
|
||||
|
||||
await useBaseFetch('oauth/app/' + editingId.value, {
|
||||
method: 'PATCH',
|
||||
internal: true,
|
||||
body,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
setForm(null)
|
||||
editingId.value = null
|
||||
|
||||
appModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removeApp() {
|
||||
startLoading()
|
||||
try {
|
||||
if (!editingId.value) {
|
||||
throw new Error('No editing id')
|
||||
}
|
||||
await useBaseFetch(`oauth/app/${editingId.value}`, {
|
||||
internal: true,
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
editingId.value = null
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.description-textarea {
|
||||
height: 6rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.secret_disclaimer {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.submit-row {
|
||||
padding-top: var(--gap-lg);
|
||||
}
|
||||
.uri-input-list {
|
||||
display: grid;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
.url-input-group-fixes {
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100% !important;
|
||||
flex-basis: 24rem !important;
|
||||
}
|
||||
}
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-submission {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.token-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-content {
|
||||
display: grid;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.token-icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-heading {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-gray-700);
|
||||
|
||||
margin-top: var(--spacing-card-md);
|
||||
margin-bottom: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
|
||||
// For the children override the padding so that y padding is --gap-sm and x padding is --gap-lg
|
||||
// Knossos global styling breaks everything
|
||||
> * {
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
245
apps/frontend/src/pages/settings/authorizations.vue
Normal file
245
apps/frontend/src/pages/settings/authorizations.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to revoke this application?"
|
||||
description="This will revoke the application's access to your account. You can always re-authorize it later."
|
||||
proceed-label="Revoke"
|
||||
@proceed="revokeApp(revokingId)"
|
||||
/>
|
||||
<h2>{{ formatMessage(commonSettingsMessages.authorizedApps) }}</h2>
|
||||
<p>
|
||||
When you authorize an application with your Modrinth account, you grant it access to your
|
||||
account. You can manage and review access to your account here at any time.
|
||||
</p>
|
||||
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
|
||||
You have not authorized any applications.
|
||||
</div>
|
||||
<div
|
||||
v-for="authorization in appInfoLookup"
|
||||
:key="authorization.id"
|
||||
class="universal-card recessed token"
|
||||
>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
<div class="icon-name">
|
||||
<Avatar :src="authorization.app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">
|
||||
{{ authorization.app.name }}
|
||||
</h2>
|
||||
<div>
|
||||
by
|
||||
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
|
||||
authorization.owner.username
|
||||
}}</nuxt-link>
|
||||
<template v-if="authorization.app.url">
|
||||
<span> ⋅ </span>
|
||||
<nuxt-link class="text-link" :to="authorization.app.url">
|
||||
{{ authorization.app.url }}
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="authorization.app.description">
|
||||
<label for="app-description">
|
||||
<span class="label__title"> About this app </span>
|
||||
</label>
|
||||
<div id="app-description">{{ authorization.app.description }}</div>
|
||||
</template>
|
||||
|
||||
<label for="app-scope-list">
|
||||
<span class="label__title">Scopes</span>
|
||||
</label>
|
||||
<div class="scope-list">
|
||||
<div
|
||||
v-for="scope in scopesToDefinitions(authorization.scopes)"
|
||||
:key="scope"
|
||||
class="scope-list-item"
|
||||
>
|
||||
<div class="scope-list-item-icon">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
{{ scope }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<Button
|
||||
color="danger"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
revokingId = authorization.app_id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, ConfirmModal, Avatar } from '@modrinth/ui'
|
||||
import { TrashIcon, CheckIcon } from '@modrinth/assets'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
import { useScopes } from '~/composables/auth/scopes.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { scopesToDefinitions } = useScopes()
|
||||
|
||||
const revokingId = ref(null)
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Authorizations - Modrinth',
|
||||
})
|
||||
|
||||
const { data: usersApps, refresh } = await useAsyncData('userAuthorizations', () =>
|
||||
useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
})
|
||||
)
|
||||
|
||||
const { data: appInformation } = await useAsyncData(
|
||||
'appInfo',
|
||||
() =>
|
||||
useBaseFetch('oauth/apps', {
|
||||
internal: true,
|
||||
query: {
|
||||
ids: usersApps.value.map((c) => c.app_id).join(','),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: usersApps,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: appCreatorsInformation } = await useAsyncData(
|
||||
'appCreatorsInfo',
|
||||
() =>
|
||||
useBaseFetch('users', {
|
||||
query: {
|
||||
ids: JSON.stringify(appInformation.value.map((c) => c.created_by)),
|
||||
},
|
||||
}),
|
||||
{
|
||||
watch: appInformation,
|
||||
}
|
||||
)
|
||||
|
||||
const appInfoLookup = computed(() => {
|
||||
return usersApps.value.map((app) => {
|
||||
const info = appInformation.value.find((c) => c.id === app.app_id)
|
||||
const owner = appCreatorsInformation.value.find((c) => c.id === info.created_by)
|
||||
return {
|
||||
...app,
|
||||
app: info || null,
|
||||
owner: owner || null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function revokeApp(id) {
|
||||
try {
|
||||
await useBaseFetch(`oauth/authorizations`, {
|
||||
internal: true,
|
||||
method: 'DELETE',
|
||||
query: {
|
||||
client_id: id,
|
||||
},
|
||||
})
|
||||
revokingId.value = null
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-group {
|
||||
// Overrides for omorphia compat
|
||||
> * {
|
||||
padding: var(--gap-sm) var(--gap-lg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.scope-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--color-gray-200);
|
||||
color: var(--color-gray-700);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
|
||||
// avoid breaking text or overflowing
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scope-list-item-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: 0 0 auto;
|
||||
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-green);
|
||||
color: var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-name {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-lg);
|
||||
padding-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.token-content {
|
||||
width: 100%;
|
||||
|
||||
.token-title {
|
||||
margin-bottom: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
646
apps/frontend/src/pages/settings/index.vue
Normal file
646
apps/frontend/src/pages/settings/index.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<template>
|
||||
<div>
|
||||
<MessageBanner v-if="flags.developerMode" message-type="warning" class="developer-message">
|
||||
<CodeIcon />
|
||||
<IntlFormatted :message-id="developerModeBanner.description">
|
||||
<template #strong="{ children }">
|
||||
<strong>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</strong>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<Button :action="() => disableDeveloperMode()">
|
||||
{{ formatMessage(developerModeBanner.deactivate) }}
|
||||
</Button>
|
||||
</MessageBanner>
|
||||
<section class="universal-card">
|
||||
<h2>{{ formatMessage(colorTheme.title) }}</h2>
|
||||
<p>{{ formatMessage(colorTheme.description) }}</p>
|
||||
<div class="theme-options">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option"
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: theme.preference === option }"
|
||||
@click="() => updateColorTheme(option)"
|
||||
>
|
||||
<div class="preview" :class="`${option === 'system' ? systemTheme : option}-mode`">
|
||||
<div class="example-card card card">
|
||||
<div class="example-icon"></div>
|
||||
<div class="example-text-1"></div>
|
||||
<div class="example-text-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked v-if="theme.preference === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
|
||||
<SunIcon
|
||||
v-if="'light' === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredLight)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
<MoonIcon
|
||||
v-else-if="(cosmetics.preferredDarkTheme ?? 'dark') === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredDark)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2>{{ formatMessage(projectListLayouts.title) }}</h2>
|
||||
<p>{{ formatMessage(projectListLayouts.description) }}</p>
|
||||
<div class="project-lists">
|
||||
<div v-for="projectType in listTypes" :key="projectType.id + '-project-list-layouts'">
|
||||
<div class="label">
|
||||
<div class="label__title">
|
||||
{{
|
||||
projectListLayouts[projectType.id]
|
||||
? formatMessage(projectListLayouts[projectType.id])
|
||||
: projectType.id
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-list-layouts">
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'list' }"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'list')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-list-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Rows
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'grid' }"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-grid-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Grid
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery' }"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-gallery-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
|
||||
class="radio"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
Gallery
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2>{{ formatMessage(toggleFeatures.title) }}</h2>
|
||||
<p>{{ formatMessage(toggleFeatures.description) }}</p>
|
||||
<div class="adjacent-input small">
|
||||
<label for="advanced-rendering">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="advanced-rendering"
|
||||
v-model="cosmetics.advancedRendering"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="external-links-new-tab">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="external-links-new-tab"
|
||||
v-model="cosmetics.externalLinksNewTab"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="modrinth-app-promos">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="modrinth-app-promos"
|
||||
v-model="cosmetics.hideModrinthAppPromos"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="search-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="search-layout-toggle"
|
||||
v-model="cosmetics.searchLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input small">
|
||||
<label for="project-layout-toggle">
|
||||
<span class="label__title">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarTitle) }}
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="project-layout-toggle"
|
||||
v-model="cosmetics.projectLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CodeIcon, RadioButtonIcon, RadioButtonChecked, SunIcon, MoonIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import { formatProjectType } from '~/plugins/shorthands.js'
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import { DARK_THEMES } from '~/composables/theme.js'
|
||||
|
||||
useHead({
|
||||
title: 'Display settings - Modrinth',
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const developerModeBanner = defineMessages({
|
||||
description: {
|
||||
id: 'settings.display.banner.developer-mode.description',
|
||||
defaultMessage:
|
||||
"<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode.",
|
||||
},
|
||||
deactivate: {
|
||||
id: 'settings.display.banner.developer-mode.button',
|
||||
defaultMessage: 'Deactivate developer mode',
|
||||
},
|
||||
})
|
||||
|
||||
const colorTheme = defineMessages({
|
||||
title: {
|
||||
id: 'settings.display.theme.title',
|
||||
defaultMessage: 'Color theme',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.theme.description',
|
||||
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
|
||||
},
|
||||
system: {
|
||||
id: 'settings.display.theme.system',
|
||||
defaultMessage: 'Sync with system',
|
||||
},
|
||||
light: {
|
||||
id: 'settings.display.theme.light',
|
||||
defaultMessage: 'Light',
|
||||
},
|
||||
dark: {
|
||||
id: 'settings.display.theme.dark',
|
||||
defaultMessage: 'Dark',
|
||||
},
|
||||
oled: {
|
||||
id: 'settings.display.theme.oled',
|
||||
defaultMessage: 'OLED',
|
||||
},
|
||||
retro: {
|
||||
id: 'settings.display.theme.retro',
|
||||
defaultMessage: 'Retro',
|
||||
},
|
||||
preferredLight: {
|
||||
id: 'settings.display.theme.preferred-light-theme',
|
||||
defaultMessage: 'Preferred light theme',
|
||||
},
|
||||
preferredDark: {
|
||||
id: 'settings.display.theme.preferred-dark-theme',
|
||||
defaultMessage: 'Preferred dark theme',
|
||||
},
|
||||
})
|
||||
|
||||
const projectListLayouts = defineMessages({
|
||||
title: {
|
||||
id: 'settings.display.project-list-layouts.title',
|
||||
defaultMessage: 'Project list layouts',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.project-list-layouts.description',
|
||||
defaultMessage:
|
||||
'Select your preferred layout for each page that displays project lists on this device.',
|
||||
},
|
||||
mod: {
|
||||
id: 'settings.display.project-list-layouts.mod',
|
||||
defaultMessage: 'Mods page',
|
||||
},
|
||||
plugin: {
|
||||
id: 'settings.display.project-list-layouts.plugin',
|
||||
defaultMessage: 'Plugins page',
|
||||
},
|
||||
datapack: {
|
||||
id: 'settings.display.project-list-layouts.datapack',
|
||||
defaultMessage: 'Data Packs page',
|
||||
},
|
||||
shader: {
|
||||
id: 'settings.display.project-list-layouts.shader',
|
||||
defaultMessage: 'Shaders page',
|
||||
},
|
||||
resourcepack: {
|
||||
id: 'settings.display.project-list-layouts.resourcepack',
|
||||
defaultMessage: 'Resource Packs page',
|
||||
},
|
||||
modpack: {
|
||||
id: 'settings.display.project-list-layouts.modpack',
|
||||
defaultMessage: 'Modpacks page',
|
||||
},
|
||||
user: {
|
||||
id: 'settings.display.project-list-layouts.user',
|
||||
defaultMessage: 'User profile pages',
|
||||
},
|
||||
})
|
||||
|
||||
const toggleFeatures = defineMessages({
|
||||
title: {
|
||||
id: 'settings.display.flags.title',
|
||||
defaultMessage: 'Toggle features',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.display.flags.description',
|
||||
defaultMessage: 'Enable or disable certain features on this device.',
|
||||
},
|
||||
advancedRenderingTitle: {
|
||||
id: 'settings.display.sidebar.advanced-rendering.title',
|
||||
defaultMessage: 'Advanced rendering',
|
||||
},
|
||||
advancedRenderingDescription: {
|
||||
id: 'settings.display.sidebar.advanced-rendering.description',
|
||||
defaultMessage:
|
||||
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
|
||||
},
|
||||
externalLinksNewTabTitle: {
|
||||
id: 'settings.display.sidebar.external-links-new-tab.title',
|
||||
defaultMessage: 'Open external links in new tab',
|
||||
},
|
||||
externalLinksNewTabDescription: {
|
||||
id: 'settings.display.sidebar.external-links-new-tab.description',
|
||||
defaultMessage:
|
||||
'Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.',
|
||||
},
|
||||
hideModrinthAppPromosTitle: {
|
||||
id: 'settings.display.sidebar.hide-app-promos.title',
|
||||
defaultMessage: 'Hide Modrinth App promotions',
|
||||
},
|
||||
hideModrinthAppPromosDescription: {
|
||||
id: 'settings.display.sidebar.hide-app-promos.description',
|
||||
defaultMessage:
|
||||
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
|
||||
},
|
||||
rightAlignedSearchSidebarTitle: {
|
||||
id: 'settings.display.sidebar.right-aligned-search-sidebar.title',
|
||||
defaultMessage: 'Right-aligned search sidebar',
|
||||
},
|
||||
rightAlignedSearchSidebarDescription: {
|
||||
id: 'settings.display.sidebar.right-aligned-search-sidebar.description',
|
||||
defaultMessage: 'Aligns the search filters sidebar to the right of the search results.',
|
||||
},
|
||||
rightAlignedProjectSidebarTitle: {
|
||||
id: 'settings.display.sidebar.right-aligned-project-sidebar.title',
|
||||
defaultMessage: 'Right-aligned project sidebar',
|
||||
},
|
||||
rightAlignedProjectSidebarDescription: {
|
||||
id: 'settings.display.sidebar.right-aligned-project-sidebar.description',
|
||||
defaultMessage: "Aligns the project details sidebar to the right of the page's content.",
|
||||
},
|
||||
})
|
||||
|
||||
const cosmetics = useCosmetics()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useTags()
|
||||
|
||||
const systemTheme = ref('light')
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
const themeOptions = computed(() => {
|
||||
const options = ['system', 'light', 'dark', 'oled']
|
||||
if (flags.value.developerMode || theme.value.preference === 'retro') {
|
||||
options.push('retro')
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateSystemTheme()
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (event) => {
|
||||
setSystemTheme(event.matches)
|
||||
})
|
||||
})
|
||||
|
||||
function updateSystemTheme() {
|
||||
const query = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setSystemTheme(query.matches)
|
||||
}
|
||||
|
||||
function setSystemTheme(light) {
|
||||
if (light) {
|
||||
systemTheme.value = 'light'
|
||||
} else {
|
||||
systemTheme.value = cosmetics.value.preferredDarkTheme ?? 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
function updateColorTheme(value) {
|
||||
if (DARK_THEMES.includes(value)) {
|
||||
cosmetics.value.preferredDarkTheme = value
|
||||
saveCosmetics()
|
||||
updateSystemTheme()
|
||||
}
|
||||
updateTheme(value, true)
|
||||
}
|
||||
|
||||
function disableDeveloperMode() {
|
||||
flags.value.developerMode = !flags.value.developerMode
|
||||
saveFeatureFlags()
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'Developer mode deactivated',
|
||||
text: 'Developer mode has been disabled',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
const listTypes = computed(() => {
|
||||
const types = tags.value.projectTypes.map((type) => {
|
||||
return {
|
||||
id: type.id,
|
||||
name: formatProjectType(type.id) + 's',
|
||||
display: 'the ' + formatProjectType(type.id).toLowerCase() + 's search page',
|
||||
}
|
||||
})
|
||||
types.push({
|
||||
id: 'user',
|
||||
name: 'User profiles',
|
||||
display: 'user pages',
|
||||
})
|
||||
return types
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.preview-radio {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-divider);
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
|
||||
.label {
|
||||
.radio {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
background-color: var(--color-bg);
|
||||
padding: 1.5rem;
|
||||
outline: 2px solid transparent;
|
||||
width: 100%;
|
||||
|
||||
.example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
outline: 2px solid transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
.radio {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.preview .example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template: 'icon text1' 'icon text2';
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
.example-icon {
|
||||
grid-area: icon;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1,
|
||||
.example-text-2 {
|
||||
height: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1 {
|
||||
grid-area: text1;
|
||||
width: 100%;
|
||||
background-color: var(--color-base);
|
||||
}
|
||||
|
||||
.example-text-2 {
|
||||
grid-area: text2;
|
||||
width: 60%;
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
|
||||
> :first-child .label__title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.preview {
|
||||
--_layout-width: 7rem;
|
||||
--_layout-height: 4.5rem;
|
||||
--_layout-gap: 0.25rem;
|
||||
|
||||
.example-card {
|
||||
border-radius: 0.5rem;
|
||||
width: var(--_layout-width);
|
||||
height: calc((var(--_layout-height) - 3 * var(--_layout-gap)) / 4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.layout-list-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
}
|
||||
|
||||
.layout-grid-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
|
||||
.example-card {
|
||||
width: calc((var(--_layout-width) - 2 * var(--_layout-gap)) / 3);
|
||||
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-gallery-mode {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--_layout-gap);
|
||||
|
||||
.example-card {
|
||||
width: calc((var(--_layout-width) - var(--_layout-gap)) / 2);
|
||||
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-list-layouts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.preview-radio .example-card {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.preview-radio.selected .example-card {
|
||||
border-color: var(--color-brand);
|
||||
background-color: var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.developer-message {
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
margin-bottom: 2px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: var(--gap-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
527
apps/frontend/src/pages/settings/language.vue
Normal file
527
apps/frontend/src/pages/settings/language.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<script setup lang="ts">
|
||||
import Fuse from 'fuse.js/dist/fuse.basic'
|
||||
import RadioButtonIcon from '~/assets/images/utils/radio-button.svg?component'
|
||||
import RadioButtonCheckedIcon from '~/assets/images/utils/radio-button-checked.svg?component'
|
||||
import WarningIcon from '~/assets/images/utils/issues.svg?component'
|
||||
import { isModifierKeyDown } from '~/helpers/events.ts'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
const messages = defineMessages({
|
||||
languagesDescription: {
|
||||
id: 'settings.language.description',
|
||||
defaultMessage:
|
||||
'Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
|
||||
},
|
||||
automaticLocale: {
|
||||
id: 'settings.language.languages.automatic',
|
||||
defaultMessage: 'Sync with the system language',
|
||||
},
|
||||
noResults: {
|
||||
id: 'settings.language.languages.search.no-results',
|
||||
defaultMessage: 'No languages match your search.',
|
||||
},
|
||||
searchFieldDescription: {
|
||||
id: 'settings.language.languages.search-field.description',
|
||||
defaultMessage: 'Submit to focus the first search result',
|
||||
},
|
||||
searchFieldPlaceholder: {
|
||||
id: 'settings.language.languages.search-field.placeholder',
|
||||
defaultMessage: 'Search for a language...',
|
||||
},
|
||||
searchResultsAnnouncement: {
|
||||
id: 'settings.language.languages.search-results-announcement',
|
||||
defaultMessage:
|
||||
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
|
||||
},
|
||||
loadFailed: {
|
||||
id: 'settings.language.languages.load-failed',
|
||||
defaultMessage: 'Cannot load this language. Try again in a bit.',
|
||||
},
|
||||
languageLabelApplying: {
|
||||
id: 'settings.language.languages.language-label-applying',
|
||||
defaultMessage: '{label}. Applying...',
|
||||
},
|
||||
languageLabelError: {
|
||||
id: 'settings.language.languages.language-label-error',
|
||||
defaultMessage: '{label}. Error',
|
||||
},
|
||||
})
|
||||
|
||||
const categoryNames = defineMessages({
|
||||
auto: {
|
||||
id: 'settings.language.categories.auto',
|
||||
defaultMessage: 'Automatic',
|
||||
},
|
||||
default: {
|
||||
id: 'settings.language.categories.default',
|
||||
defaultMessage: 'Standard languages',
|
||||
},
|
||||
fun: {
|
||||
id: 'settings.language.categories.fun',
|
||||
defaultMessage: 'Fun languages',
|
||||
},
|
||||
experimental: {
|
||||
id: 'settings.language.categories.experimental',
|
||||
defaultMessage: 'Experimental languages',
|
||||
},
|
||||
searchResult: {
|
||||
id: 'settings.language.categories.search-result',
|
||||
defaultMessage: 'Search results',
|
||||
},
|
||||
})
|
||||
|
||||
type Category = keyof typeof categoryNames
|
||||
|
||||
const categoryOrder: Category[] = ['auto', 'default', 'fun', 'experimental']
|
||||
|
||||
function normalizeCategoryName(name?: string): keyof typeof categoryNames {
|
||||
switch (name) {
|
||||
case 'auto':
|
||||
case 'fun':
|
||||
case 'experimental':
|
||||
return name
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
type LocaleBase = {
|
||||
category: Category
|
||||
tag: string
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
type AutomaticLocale = LocaleBase & {
|
||||
auto: true
|
||||
}
|
||||
|
||||
type CommonLocale = LocaleBase & {
|
||||
auto?: never
|
||||
displayName: string
|
||||
defaultName: string
|
||||
translatedName: string
|
||||
}
|
||||
|
||||
type Locale = AutomaticLocale | CommonLocale
|
||||
|
||||
const $defaultNames = useDisplayNames(() => vintl.defaultLocale)
|
||||
|
||||
const $translatedNames = useDisplayNames(() => vintl.locale)
|
||||
|
||||
const $locales = computed(() => {
|
||||
const locales: Locale[] = []
|
||||
|
||||
locales.push({
|
||||
auto: true,
|
||||
tag: 'auto',
|
||||
category: 'auto',
|
||||
searchTerms: [
|
||||
'automatic',
|
||||
'Sync with the system language',
|
||||
formatMessage(messages.automaticLocale),
|
||||
],
|
||||
})
|
||||
|
||||
for (const locale of vintl.availableLocales) {
|
||||
let displayName = locale.meta?.displayName
|
||||
|
||||
if (displayName == null) {
|
||||
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag
|
||||
}
|
||||
|
||||
let defaultName = vintl.defaultResources['languages.json']?.[locale.tag]
|
||||
|
||||
if (defaultName == null) {
|
||||
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag
|
||||
}
|
||||
|
||||
let translatedName = vintl.resources['languages.json']?.[locale.tag]
|
||||
|
||||
if (translatedName == null) {
|
||||
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag
|
||||
}
|
||||
|
||||
let searchTerms = locale.meta?.searchTerms
|
||||
if (searchTerms === '-') searchTerms = undefined
|
||||
|
||||
locales.push({
|
||||
tag: locale.tag,
|
||||
category: normalizeCategoryName(locale.meta?.category),
|
||||
displayName,
|
||||
defaultName,
|
||||
translatedName,
|
||||
searchTerms: searchTerms?.split('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
return locales
|
||||
})
|
||||
|
||||
const $query = ref('')
|
||||
|
||||
const isQueryEmpty = () => $query.value.trim().length === 0
|
||||
|
||||
const fuse = new Fuse<Locale>([], {
|
||||
keys: ['tag', 'displayName', 'translatedName', 'englishName', 'searchTerms'],
|
||||
threshold: 0.4,
|
||||
distance: 100,
|
||||
})
|
||||
|
||||
watchSyncEffect(() => fuse.setCollection($locales.value))
|
||||
|
||||
const $categories = computed(() => {
|
||||
const categories = new Map<Category, Locale[]>()
|
||||
|
||||
for (const category of categoryOrder) categories.set(category, [])
|
||||
|
||||
for (const locale of $locales.value) {
|
||||
let categoryLocales = categories.get(locale.category)
|
||||
|
||||
if (categoryLocales == null) {
|
||||
categoryLocales = []
|
||||
categories.set(locale.category, categoryLocales)
|
||||
}
|
||||
|
||||
categoryLocales.push(locale)
|
||||
}
|
||||
|
||||
for (const categoryKey of [...categories.keys()]) {
|
||||
if (categories.get(categoryKey)?.length === 0) {
|
||||
categories.delete(categoryKey)
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
})
|
||||
|
||||
const $searchResults = computed(() => {
|
||||
return new Map<Category, Locale[]>([
|
||||
['searchResult', isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
|
||||
])
|
||||
})
|
||||
|
||||
const $displayCategories = computed(() =>
|
||||
isQueryEmpty() ? $categories.value : $searchResults.value
|
||||
)
|
||||
|
||||
const $changingTo = ref<string | undefined>()
|
||||
|
||||
const isChanging = () => $changingTo.value != null
|
||||
|
||||
const $failedLocale = ref<string>()
|
||||
|
||||
const $activeLocale = computed(() => {
|
||||
if ($changingTo.value != null) return $changingTo.value
|
||||
return vintl.automatic ? 'auto' : vintl.locale
|
||||
})
|
||||
|
||||
async function changeLocale(value: string) {
|
||||
if ($activeLocale.value === value) return
|
||||
|
||||
$changingTo.value = value
|
||||
|
||||
try {
|
||||
await vintl.changeLocale(value)
|
||||
$failedLocale.value = undefined
|
||||
} catch (err) {
|
||||
$failedLocale.value = value
|
||||
} finally {
|
||||
$changingTo.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const $languagesList = ref<HTMLDivElement | undefined>()
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
|
||||
|
||||
const focusableTarget = $languagesList.value?.querySelector(
|
||||
'input, [tabindex]:not([tabindex="-1"])'
|
||||
) as HTMLElement | undefined
|
||||
|
||||
focusableTarget?.focus()
|
||||
}
|
||||
|
||||
function onItemKeydown(e: KeyboardEvent, locale: Locale) {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (isModifierKeyDown(e) || isChanging()) return
|
||||
|
||||
changeLocale(locale.tag)
|
||||
}
|
||||
|
||||
function onItemClick(e: MouseEvent, locale: Locale) {
|
||||
if (isModifierKeyDown(e) || isChanging()) return
|
||||
|
||||
changeLocale(locale.tag)
|
||||
}
|
||||
|
||||
function getItemLabel(locale: Locale) {
|
||||
const label = locale.auto
|
||||
? formatMessage(messages.automaticLocale)
|
||||
: `${locale.translatedName}. ${locale.displayName}`
|
||||
|
||||
if ($changingTo.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelApplying, { label })
|
||||
}
|
||||
|
||||
if ($failedLocale.value === locale.tag) {
|
||||
return formatMessage(messages.languageLabelError, { label })
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>{{ formatMessage(commonSettingsMessages.language) }}</h2>
|
||||
|
||||
<div class="card-description">
|
||||
<IntlFormatted :message-id="messages.languagesDescription">
|
||||
<template #crowdin-link="{ children }">
|
||||
<a href="https://crowdin.com/project/modrinth">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input
|
||||
id="language-search"
|
||||
v-model="$query"
|
||||
name="language"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
|
||||
class="language-search"
|
||||
aria-describedby="language-search-description"
|
||||
:disabled="isChanging()"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
|
||||
<div id="language-search-description" class="visually-hidden">
|
||||
{{ formatMessage(messages.searchFieldDescription) }}
|
||||
</div>
|
||||
|
||||
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
|
||||
{{
|
||||
isQueryEmpty()
|
||||
? ''
|
||||
: formatMessage(messages.searchResultsAnnouncement, {
|
||||
matches: $searchResults.get('searchResult')?.length ?? 0,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="$languagesList" class="languages-list">
|
||||
<template v-for="[category, locales] in $displayCategories" :key="category">
|
||||
<strong class="category-name">
|
||||
{{ formatMessage(categoryNames[category]) }}
|
||||
</strong>
|
||||
|
||||
<div
|
||||
v-if="category === 'searchResult' && locales.length === 0"
|
||||
class="no-results"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ formatMessage(messages.noResults) }}
|
||||
</div>
|
||||
|
||||
<template v-for="locale in locales" :key="locale.tag">
|
||||
<div
|
||||
role="button"
|
||||
:aria-pressed="$activeLocale === locale.tag"
|
||||
:class="{
|
||||
'language-item': true,
|
||||
pending: $changingTo == locale.tag,
|
||||
errored: $failedLocale == locale.tag,
|
||||
}"
|
||||
:aria-describedby="
|
||||
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
|
||||
"
|
||||
:aria-disabled="isChanging() && $changingTo !== locale.tag"
|
||||
:tabindex="0"
|
||||
:aria-label="getItemLabel(locale)"
|
||||
@click="(e) => onItemClick(e, locale)"
|
||||
@keydown="(e) => onItemKeydown(e, locale)"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
|
||||
<div class="language-names">
|
||||
<div class="language-name">
|
||||
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
|
||||
</div>
|
||||
|
||||
<div v-if="!locale.auto" class="language-translated-name">
|
||||
{{ locale.translatedName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$failedLocale === locale.tag"
|
||||
:id="`language__${locale.tag}__fail`"
|
||||
class="language-load-error"
|
||||
>
|
||||
<WarningIcon /> {{ formatMessage(messages.loadFailed) }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.languages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
border: 0.15rem solid transparent;
|
||||
border-radius: var(--spacing-card-md);
|
||||
background: var(--color-button-bg);
|
||||
padding: var(--spacing-card-md);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:not([aria-disabled='true']):hover {
|
||||
border-color: var(--color-button-bg-hover);
|
||||
}
|
||||
|
||||
&:focus-visible,
|
||||
&:has(:focus-visible) {
|
||||
outline: 2px solid var(--color-brand);
|
||||
}
|
||||
|
||||
&.errored {
|
||||
border-color: var(--color-red);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
}
|
||||
|
||||
&.pending::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-image: linear-gradient(
|
||||
102deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0) 20%,
|
||||
rgba(0, 0, 0, 0.1) 45%,
|
||||
rgba(0, 0, 0, 0.1) 50%,
|
||||
rgba(0, 0, 0, 0) 80%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
|
||||
background-repeat: no-repeat;
|
||||
animation: shimmerSliding 2.5s ease-out infinite;
|
||||
|
||||
.dark-mode &,
|
||||
.oled-mode & {
|
||||
background-image: linear-gradient(
|
||||
102deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) 20%,
|
||||
rgba(255, 255, 255, 0.1) 45%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 80%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes shimmerSliding {
|
||||
from {
|
||||
left: -100%;
|
||||
}
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-disabled='true']:not(.pending) {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.language-load-error {
|
||||
color: var(--color-red);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-left: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.language-names {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.language-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.language-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-link-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
</style>
|
||||
428
apps/frontend/src/pages/settings/pats.vue
Normal file
428
apps/frontend/src/pages/settings/pats.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
:title="formatMessage(deleteModalMessages.title)"
|
||||
:description="formatMessage(deleteModalMessages.description)"
|
||||
:proceed-label="formatMessage(deleteModalMessages.action)"
|
||||
@proceed="removePat(deletePatIndex)"
|
||||
/>
|
||||
<Modal
|
||||
ref="patModal"
|
||||
:header="
|
||||
editPatIndex !== null
|
||||
? formatMessage(createModalMessages.editTitle)
|
||||
: formatMessage(createModalMessages.createTitle)
|
||||
"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<label for="pat-name">
|
||||
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="pat-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="formatMessage(createModalMessages.namePlaceholder)"
|
||||
/>
|
||||
<label for="pat-scopes">
|
||||
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
|
||||
</label>
|
||||
<div id="pat-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="scope in scopeList"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
|
||||
/>
|
||||
</div>
|
||||
<label for="pat-name">
|
||||
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
|
||||
</label>
|
||||
<input id="pat-name" v-model="expires" type="date" />
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.patModal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editPatIndex !== null"
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="editPat"
|
||||
>
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="createPat"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(createModalMessages.action) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2>{{ formatMessage(commonSettingsMessages.pats) }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
() => {
|
||||
name = null
|
||||
scopesVal = 0
|
||||
expires = null
|
||||
editPatIndex = null
|
||||
$refs.patModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> {{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #doc-link="{ children }">
|
||||
<a class="text-link" href="https://docs.modrinth.com">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<div v-for="(pat, index) in pats" :key="pat.id" class="universal-card recessed token">
|
||||
<div>
|
||||
<div>
|
||||
<strong>{{ pat.name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="pat.access_token">
|
||||
<CopyCode :text="pat.access_token" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-tooltip="
|
||||
pat.last_used
|
||||
? formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.last_used),
|
||||
time: new Date(pat.last_used),
|
||||
})
|
||||
: null
|
||||
"
|
||||
>
|
||||
<template v-if="pat.last_used">
|
||||
{{
|
||||
formatMessage(tokenMessages.lastUsed, {
|
||||
ago: formatRelativeTime(pat.last_used),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>{{ formatMessage(tokenMessages.neverUsed) }}</template>
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.expires),
|
||||
time: new Date(pat.expires),
|
||||
})
|
||||
"
|
||||
>
|
||||
<template v-if="new Date(pat.expires) > new Date()">
|
||||
{{
|
||||
formatMessage(tokenMessages.expiresIn, {
|
||||
inTime: formatRelativeTime(pat.expires),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
formatMessage(tokenMessages.expiredAgo, {
|
||||
ago: formatRelativeTime(pat.expires),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(pat.created),
|
||||
time: new Date(pat.created),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(commonMessages.createdAgoLabel, {
|
||||
ago: formatRelativeTime(pat.created),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
() => {
|
||||
editPatIndex = index
|
||||
name = pat.name
|
||||
scopesVal = pat.scopes
|
||||
expires = $dayjs(pat.expires).format('YYYY-MM-DD')
|
||||
$refs.patModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(tokenMessages.edit) }}
|
||||
</button>
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
() => {
|
||||
deletePatIndex = pat.id
|
||||
$refs.modal_confirm.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon /> {{ formatMessage(tokenMessages.revoke) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from '@modrinth/assets'
|
||||
import { Checkbox, ConfirmModal } from '@modrinth/ui'
|
||||
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
import {
|
||||
hasScope,
|
||||
scopeList,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
getScopeValue,
|
||||
} from '~/composables/auth/scopes.ts'
|
||||
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const createModalMessages = defineMessages({
|
||||
createTitle: {
|
||||
id: 'settings.pats.modal.create.title',
|
||||
defaultMessage: 'Create personal access token',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'settings.pats.modal.edit.title',
|
||||
defaultMessage: 'Edit personal access token',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'settings.pats.modal.create.name.label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'settings.pats.modal.create.name.placeholder',
|
||||
defaultMessage: "Enter the PAT's name...",
|
||||
},
|
||||
expiresLabel: {
|
||||
id: 'settings.pats.modal.create.expires.label',
|
||||
defaultMessage: 'Expires',
|
||||
},
|
||||
action: {
|
||||
id: 'settings.pats.modal.create.action',
|
||||
defaultMessage: 'Create PAT',
|
||||
},
|
||||
})
|
||||
|
||||
const deleteModalMessages = defineMessages({
|
||||
title: {
|
||||
id: 'settings.pats.modal.delete.title',
|
||||
defaultMessage: 'Are you sure you want to delete this token?',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.pats.modal.delete.description',
|
||||
defaultMessage: 'This will remove this token forever (like really forever).',
|
||||
},
|
||||
action: {
|
||||
id: 'settings.pats.modal.delete.action',
|
||||
defaultMessage: 'Delete this token',
|
||||
},
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
description: {
|
||||
id: 'settings.pats.description',
|
||||
defaultMessage:
|
||||
"PATs can be used to access Modrinth's API. For more information, see <doc-link>Modrinth's API documentation</doc-link>. They can be created and revoked at any time.",
|
||||
},
|
||||
create: {
|
||||
id: 'settings.pats.action.create',
|
||||
defaultMessage: 'Create a PAT',
|
||||
},
|
||||
})
|
||||
|
||||
const tokenMessages = defineMessages({
|
||||
edit: {
|
||||
id: 'settings.pats.token.action.edit',
|
||||
defaultMessage: 'Edit token',
|
||||
},
|
||||
revoke: {
|
||||
id: 'settings.pats.token.action.revoke',
|
||||
defaultMessage: 'Revoke token',
|
||||
},
|
||||
lastUsed: {
|
||||
id: 'settings.pats.token.last-used',
|
||||
defaultMessage: 'Last used {ago}',
|
||||
},
|
||||
neverUsed: {
|
||||
id: 'settings.pats.token.never-used',
|
||||
defaultMessage: 'Never used',
|
||||
},
|
||||
expiresIn: {
|
||||
id: 'settings.pats.token.expires-in',
|
||||
defaultMessage: 'Expires {inTime}',
|
||||
},
|
||||
expiredAgo: {
|
||||
id: 'settings.pats.token.expired-ago',
|
||||
defaultMessage: 'Expired {ago}',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: `${formatMessage(commonSettingsMessages.pats)} - Modrinth`,
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { scopesToLabels } = useScopes()
|
||||
const patModal = ref()
|
||||
|
||||
const editPatIndex = ref(null)
|
||||
|
||||
const name = ref(null)
|
||||
const scopesVal = ref(BigInt(0))
|
||||
const expires = ref(null)
|
||||
|
||||
const deletePatIndex = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const { data: pats, refresh } = await useAsyncData('pat', () => useBaseFetch('pat'))
|
||||
|
||||
async function createPat() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await useBaseFetch('pat', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
})
|
||||
pats.value.push(res)
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function editPat() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
await useBaseFetch(`pat/${pats.value[editPatIndex.value].id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
})
|
||||
await refresh()
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removePat(id) {
|
||||
startLoading()
|
||||
try {
|
||||
pats.value = pats.value.filter((x) => x.id !== id)
|
||||
await useBaseFetch(`pat/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
apps/frontend/src/pages/settings/profile.vue
Normal file
234
apps/frontend/src/pages/settings/profile.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="card">
|
||||
<h2>{{ formatMessage(messages.title) }}</h2>
|
||||
<p>
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #docs-link="{ children }">
|
||||
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<label>
|
||||
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
|
||||
</label>
|
||||
<div class="avatar-changer">
|
||||
<Avatar
|
||||
:src="previewImage ? previewImage : avatarUrl"
|
||||
size="md"
|
||||
circle
|
||||
:alt="auth.user.username"
|
||||
/>
|
||||
<div class="input-stack">
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
class="btn"
|
||||
:prompt="formatMessage(commonMessages.uploadImageButton)"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<Button
|
||||
v-if="previewImage"
|
||||
:action="
|
||||
() => {
|
||||
icon = null
|
||||
previewImage = null
|
||||
}
|
||||
"
|
||||
>
|
||||
<UndoIcon />
|
||||
{{ formatMessage(messages.profilePictureReset) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<label for="username-field">
|
||||
<span class="label__title">{{ formatMessage(messages.usernameTitle) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<input id="username-field" v-model="username" type="text" />
|
||||
<label for="bio-field">
|
||||
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.bioDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea id="bio-field" v-model="bio" type="text" />
|
||||
<div v-if="hasUnsavedChanges" class="input-group">
|
||||
<Button color="primary" :action="() => saveChanges()">
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</Button>
|
||||
<Button :action="() => cancel()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="input-group">
|
||||
<Button disabled color="primary" :action="() => saveChanges()">
|
||||
<SaveIcon />
|
||||
{{
|
||||
saved
|
||||
? formatMessage(commonMessages.changesSavedLabel)
|
||||
: formatMessage(commonMessages.saveChangesButton)
|
||||
}}
|
||||
</Button>
|
||||
<Button :link="`/user/${auth.user.username}`">
|
||||
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, FileInput, Button } from '@modrinth/ui'
|
||||
import { commonMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
useHead({
|
||||
title: 'Profile settings - Modrinth',
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'settings.profile.profile-info',
|
||||
defaultMessage: 'Profile information',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.profile.description',
|
||||
defaultMessage:
|
||||
'Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.',
|
||||
},
|
||||
profilePicture: {
|
||||
id: 'settings.profile.profile-picture.title',
|
||||
defaultMessage: 'Profile picture',
|
||||
},
|
||||
profilePictureReset: {
|
||||
id: 'settings.profile.profile-picture.reset',
|
||||
defaultMessage: 'Reset',
|
||||
},
|
||||
usernameTitle: {
|
||||
id: 'settings.profile.username.title',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
usernameDescription: {
|
||||
id: 'settings.profile.username.description',
|
||||
defaultMessage: 'A unique case-insensitive name to identify your profile.',
|
||||
},
|
||||
bioTitle: {
|
||||
id: 'settings.profile.bio.title',
|
||||
defaultMessage: 'Bio',
|
||||
},
|
||||
bioDescription: {
|
||||
id: 'settings.profile.bio.description',
|
||||
defaultMessage: 'A short description to tell everyone a little bit about you.',
|
||||
},
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const username = ref(auth.value.user.username)
|
||||
const bio = ref(auth.value.user.bio)
|
||||
const avatarUrl = ref(auth.value.user.avatar_url)
|
||||
const icon = shallowRef(null)
|
||||
const previewImage = shallowRef(null)
|
||||
const saved = ref(false)
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
username.value !== auth.value.user.username ||
|
||||
bio.value !== auth.value.user.bio ||
|
||||
previewImage.value
|
||||
)
|
||||
|
||||
function showPreviewImage(files) {
|
||||
const reader = new FileReader()
|
||||
icon.value = files[0]
|
||||
reader.readAsDataURL(icon.value)
|
||||
reader.onload = (event) => {
|
||||
previewImage.value = event.target.result
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
username.value = auth.value.user.username
|
||||
bio.value = auth.value.user.bio
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
startLoading()
|
||||
try {
|
||||
if (icon.value) {
|
||||
await useBaseFetch(
|
||||
`user/${auth.value.user.id}/icon?ext=${
|
||||
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
|
||||
}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: icon.value,
|
||||
}
|
||||
)
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
const body = {}
|
||||
|
||||
if (auth.value.user.username !== username.value) {
|
||||
body.username = username.value
|
||||
}
|
||||
|
||||
if (auth.value.user.bio !== bio.value) {
|
||||
body.bio = bio.value
|
||||
}
|
||||
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
await useAuth(auth.value.token)
|
||||
avatarUrl.value = auth.value.user.avatar_url
|
||||
saved.value = true
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err
|
||||
? err.data
|
||||
? err.data.description
|
||||
? err.data.description
|
||||
: err.data
|
||||
: err
|
||||
: 'aaaaahhh',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.avatar-changer {
|
||||
display: flex;
|
||||
gap: var(--gap-lg);
|
||||
margin-top: var(--gap-md);
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 6rem;
|
||||
width: 40rem;
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
</style>
|
||||
144
apps/frontend/src/pages/settings/sessions.vue
Normal file
144
apps/frontend/src/pages/settings/sessions.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<h2>{{ formatMessage(commonSettingsMessages.sessions) }}</h2>
|
||||
<p class="preserve-lines">
|
||||
{{ formatMessage(messages.sessionsDescription) }}
|
||||
</p>
|
||||
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session">
|
||||
<div>
|
||||
<div>
|
||||
<strong>
|
||||
{{ session.os ?? formatMessage(messages.unknownOsLabel) }} ⋅
|
||||
{{ session.platform ?? formatMessage(messages.unknownPlatformLabel) }} ⋅
|
||||
{{ session.ip }}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="session.city">{{ session.city }}, {{ session.country }} ⋅ </template>
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(session.last_login),
|
||||
time: new Date(session.last_login),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.lastAccessedAgoLabel, {
|
||||
ago: formatRelativeTime(session.last_login),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
⋅
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(session.created),
|
||||
time: new Date(session.created),
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.createdAgoLabel, {
|
||||
ago: formatRelativeTime(session.created),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<i v-if="session.current">{{ formatMessage(messages.currentSessionLabel) }}</i>
|
||||
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
|
||||
<XIcon /> {{ formatMessage(messages.revokeSessionButton) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { commonSettingsMessages } from '~/utils/common-messages.ts'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const messages = defineMessages({
|
||||
currentSessionLabel: {
|
||||
id: 'settings.sessions.current-session',
|
||||
defaultMessage: 'Current session',
|
||||
},
|
||||
revokeSessionButton: {
|
||||
id: 'settings.sessions.action.revoke-session',
|
||||
defaultMessage: 'Revoke session',
|
||||
},
|
||||
createdAgoLabel: {
|
||||
id: 'settings.sessions.created-ago',
|
||||
defaultMessage: 'Created {ago}',
|
||||
},
|
||||
sessionsDescription: {
|
||||
id: 'settings.sessions.description',
|
||||
defaultMessage:
|
||||
"Here are all the devices that are currently logged in with your Modrinth account. You can log out of each one individually.\n\nIf you see an entry you don't recognize, log out of that device and change your Modrinth account password immediately.",
|
||||
},
|
||||
lastAccessedAgoLabel: {
|
||||
id: 'settings.sessions.last-accessed-ago',
|
||||
defaultMessage: 'Last accessed {ago}',
|
||||
},
|
||||
unknownOsLabel: {
|
||||
id: 'settings.sessions.unknown-os',
|
||||
defaultMessage: 'Unknown OS',
|
||||
},
|
||||
unknownPlatformLabel: {
|
||||
id: 'settings.sessions.unknown-platform',
|
||||
defaultMessage: 'Unknown platform',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(commonSettingsMessages.sessions)} - Modrinth`,
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { data: sessions, refresh } = await useAsyncData('session/list', () =>
|
||||
useBaseFetch('session/list')
|
||||
)
|
||||
|
||||
async function revokeSession(id) {
|
||||
startLoading()
|
||||
try {
|
||||
sessions.value = sessions.value.filter((x) => x.id !== id)
|
||||
await useBaseFetch(`session/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
685
apps/frontend/src/pages/user/[id].vue
Normal file
685
apps/frontend/src/pages/user/[id].vue
Normal file
@@ -0,0 +1,685 @@
|
||||
<template>
|
||||
<div v-if="user">
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<div class="user-header-wrapper">
|
||||
<div class="user-header">
|
||||
<Avatar :src="user.avatar_url" size="md" circle :alt="user.username" />
|
||||
<h1 class="username">
|
||||
{{ user.username }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<div class="card sidebar">
|
||||
<h1 class="mobile-username">
|
||||
{{ user.username }}
|
||||
</h1>
|
||||
<div class="card__overlay">
|
||||
<NuxtLink
|
||||
v-if="auth.user && auth.user.id === user.id"
|
||||
to="/settings/profile"
|
||||
class="iconified-button"
|
||||
>
|
||||
<EditIcon />
|
||||
{{ formatMessage(commonMessages.editButton) }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else-if="auth.user"
|
||||
class="iconified-button"
|
||||
@click="() => reportUser(user.id)"
|
||||
>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.profileReportButton) }}
|
||||
</button>
|
||||
<nuxt-link v-else class="iconified-button" to="/auth/sign-in">
|
||||
<ReportIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.profileReportButton) }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="sidebar__item">
|
||||
<Badge v-if="tags.staffRoles.includes(user.role)" :type="user.role" />
|
||||
<Badge v-else-if="projects.length > 0" type="creator" />
|
||||
</div>
|
||||
<span v-if="user.bio" class="sidebar__item bio">{{ user.bio }}</span>
|
||||
<hr class="card-divider" />
|
||||
<div class="primary-stat">
|
||||
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<IntlFormatted
|
||||
:message-id="messages.profileDownloadsStats"
|
||||
:values="{ count: formatCompactNumber(sumDownloads) }"
|
||||
>
|
||||
<template #stat="{ children }">
|
||||
<span class="primary-stat__counter">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
<div class="primary-stat">
|
||||
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<IntlFormatted
|
||||
:message-id="messages.profileProjectsFollowersStats"
|
||||
:values="{ count: formatCompactNumber(sumFollows) }"
|
||||
>
|
||||
<template #stat="{ children }">
|
||||
<span class="primary-stat__counter">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-block__item secondary-stat">
|
||||
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
|
||||
<span
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(user.created),
|
||||
time: new Date(user.created),
|
||||
})
|
||||
"
|
||||
class="secondary-stat__text date"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.profileJoinedAt, { ago: formatRelativeTime(user.created) })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<hr class="card-divider" />
|
||||
<div class="stats-block__item secondary-stat">
|
||||
<UserIcon class="secondary-stat__icon" aria-hidden="true" />
|
||||
<span class="secondary-stat__text">
|
||||
<IntlFormatted :message-id="messages.profileUserId">
|
||||
<template #~id>
|
||||
<CopyCode :text="user.id" />
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<Promotion :external="false" query-param="" />
|
||||
<nav class="navigation-card">
|
||||
<NavRow
|
||||
:links="[
|
||||
{
|
||||
label: formatMessage(commonMessages.allProjectType),
|
||||
href: `/user/${user.username}`,
|
||||
},
|
||||
...projectTypes.map((x) => {
|
||||
return {
|
||||
label: formatMessage(getProjectTypeMessage(x, true)),
|
||||
href: `/user/${user.username}/${x}s`,
|
||||
}
|
||||
}),
|
||||
]"
|
||||
/>
|
||||
<div class="input-group">
|
||||
<NuxtLink
|
||||
v-if="auth.user && auth.user.id === user.id"
|
||||
class="iconified-button"
|
||||
to="/dashboard/projects"
|
||||
>
|
||||
<SettingsIcon />
|
||||
{{ formatMessage(messages.profileManageProjectsButton) }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="route.params.projectType !== 'collections'"
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages[`${cosmetics.searchDisplayMode.user}InputView`])
|
||||
"
|
||||
:aria-label="
|
||||
formatMessage(commonMessages[`${cosmetics.searchDisplayMode.user}InputView`])
|
||||
"
|
||||
class="square-button"
|
||||
@click="cycleSearchDisplayMode()"
|
||||
>
|
||||
<GridIcon v-if="cosmetics.searchDisplayMode.user === 'grid'" />
|
||||
<ImageIcon v-else-if="cosmetics.searchDisplayMode.user === 'gallery'" />
|
||||
<ListIcon v-else />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div v-if="projects.length > 0">
|
||||
<div
|
||||
v-if="route.params.projectType !== 'collections'"
|
||||
:class="'project-list display-mode--' + cosmetics.searchDisplayMode.user"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="project in (route.params.projectType !== undefined
|
||||
? projects.filter(
|
||||
(x) =>
|
||||
x.project_type ===
|
||||
route.params.projectType.substr(0, route.params.projectType.length - 1)
|
||||
)
|
||||
: projects
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.slug || project.id"
|
||||
:key="project.id"
|
||||
:name="project.title"
|
||||
:display="cosmetics.searchDisplayMode.user"
|
||||
:featured-image="project.gallery.find((element) => element.featured)?.url"
|
||||
:description="project.description"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:downloads="project.downloads.toString()"
|
||||
:follows="project.followers.toString()"
|
||||
:icon-url="project.icon_url"
|
||||
:categories="project.categories"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
: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>
|
||||
</div>
|
||||
<div v-else-if="route.params.projectType !== 'collections'" class="error">
|
||||
<UpToDate class="icon" /><br />
|
||||
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
||||
<IntlFormatted :message-id="messages.profileNoProjectsAuthLabel">
|
||||
<template #create-link="{ children }">
|
||||
<a class="link" @click.prevent="$refs.modal_creation.show()">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<span v-else class="text">{{ formatMessage(messages.profileNoProjectsLabel) }}</span>
|
||||
</div>
|
||||
<div v-if="['collections'].includes(route.params.projectType)" class="collections-grid">
|
||||
<nuxt-link
|
||||
v-for="collection in collections"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="card collection-item"
|
||||
>
|
||||
<div class="collection">
|
||||
<Avatar :src="collection.icon_url" class="icon" />
|
||||
<div class="details">
|
||||
<h2 class="title">{{ collection.name }}</h2>
|
||||
<div class="stats">
|
||||
<LibraryIcon />
|
||||
Collection
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ collection.description }}
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stats"><BoxIcon /> {{ collection.projects?.length || 0 }} projects</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<WorldIcon />
|
||||
<span> Public </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon />
|
||||
<span> Unlisted </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon />
|
||||
<span> Private </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon />
|
||||
<span> Rejected </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div
|
||||
v-if="route.params.projectType === 'collections' && collections.length === 0"
|
||||
class="error"
|
||||
>
|
||||
<UpToDate class="icon" /><br />
|
||||
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
||||
<IntlFormatted :message-id="messages.profileNoCollectionsAuthLabel">
|
||||
<template #create-link="{ children }">
|
||||
<a class="link" @click.prevent="$refs.modal_collection_creation.show()">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<span v-else class="text">{{ formatMessage(messages.profileNoCollectionsLabel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { LibraryIcon, BoxIcon, LinkIcon, LockIcon, XIcon } from '@modrinth/assets'
|
||||
import { Promotion } from '@modrinth/ui'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import { reportUser } from '~/utils/report-helpers.ts'
|
||||
|
||||
import ReportIcon from '~/assets/images/utils/report.svg?component'
|
||||
import SunriseIcon from '~/assets/images/utils/sunrise.svg?component'
|
||||
import DownloadIcon from '~/assets/images/utils/download.svg?component'
|
||||
import SettingsIcon from '~/assets/images/utils/settings.svg?component'
|
||||
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
|
||||
import UserIcon from '~/assets/images/utils/user.svg?component'
|
||||
import EditIcon from '~/assets/images/utils/edit.svg?component'
|
||||
import HeartIcon from '~/assets/images/utils/heart.svg?component'
|
||||
import GridIcon from '~/assets/images/utils/grid.svg?component'
|
||||
import ListIcon from '~/assets/images/utils/list.svg?component'
|
||||
import ImageIcon from '~/assets/images/utils/image.svg?component'
|
||||
import WorldIcon from '~/assets/images/utils/world.svg?component'
|
||||
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
||||
import NavRow from '~/components/ui/NavRow.vue'
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
|
||||
const data = useNuxtApp()
|
||||
const route = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
const cosmetics = useCosmetics()
|
||||
const tags = useTags()
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
const formatCompactNumber = useCompactNumber()
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const messages = defineMessages({
|
||||
profileDownloadsStats: {
|
||||
id: 'profile.stats.downloads',
|
||||
defaultMessage:
|
||||
'{count, plural, one {<stat>{count}</stat> download} other {<stat>{count}</stat> downloads}}',
|
||||
},
|
||||
profileProjectsFollowersStats: {
|
||||
id: 'profile.stats.projects-followers',
|
||||
defaultMessage:
|
||||
'{count, plural, one {<stat>{count}</stat> follower} other {<stat>{count}</stat> followers}} of projects',
|
||||
},
|
||||
profileJoinedAt: {
|
||||
id: 'profile.joined-at',
|
||||
defaultMessage: 'Joined {ago}',
|
||||
},
|
||||
profileUserId: {
|
||||
id: 'profile.user-id',
|
||||
defaultMessage: 'User ID: {id}',
|
||||
},
|
||||
profileOrganizations: {
|
||||
id: 'profile.label.organizations',
|
||||
defaultMessage: 'Organizations',
|
||||
},
|
||||
profileManageProjectsButton: {
|
||||
id: 'profile.button.manage-projects',
|
||||
defaultMessage: 'Manage projects',
|
||||
},
|
||||
profileMetaDescription: {
|
||||
id: 'profile.meta.description',
|
||||
defaultMessage: "Download {username}'s projects on Modrinth",
|
||||
},
|
||||
profileMetaDescriptionWithBio: {
|
||||
id: 'profile.meta.description-with-bio',
|
||||
defaultMessage: "{bio} - Download {username}'s projects on Modrinth",
|
||||
},
|
||||
profileReportButton: {
|
||||
id: 'profile.button.report',
|
||||
defaultMessage: 'Report',
|
||||
},
|
||||
profileNoProjectsLabel: {
|
||||
id: 'profile.label.no-projects',
|
||||
defaultMessage: 'This user has no projects!',
|
||||
},
|
||||
profileNoProjectsAuthLabel: {
|
||||
id: 'profile.label.no-projects-auth',
|
||||
defaultMessage:
|
||||
"You don't have any projects.\nWould you like to <create-link>create one</create-link>?",
|
||||
},
|
||||
profileNoCollectionsLabel: {
|
||||
id: 'profile.label.no-collections',
|
||||
defaultMessage: 'This user has no collections!',
|
||||
},
|
||||
profileNoCollectionsAuthLabel: {
|
||||
id: 'profile.label.no-collections-auth',
|
||||
defaultMessage:
|
||||
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
|
||||
},
|
||||
userNotFoundError: {
|
||||
id: 'profile.error.not-found',
|
||||
defaultMessage: 'User not found',
|
||||
},
|
||||
})
|
||||
|
||||
let user, projects, organizations, collections
|
||||
try {
|
||||
;[{ 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}/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,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
})
|
||||
}
|
||||
|
||||
if (!user.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
})
|
||||
}
|
||||
|
||||
if (user.value.username !== route.params.id) {
|
||||
await navigateTo(`/user/${user.value.username}`, { redirectCode: 301 })
|
||||
}
|
||||
|
||||
const title = computed(() => `${user.value.username} - Modrinth`)
|
||||
const description = computed(() =>
|
||||
user.value.bio
|
||||
? formatMessage(messages.profileMetaDescriptionWithBio, {
|
||||
bio: user.value.bio,
|
||||
username: user.value.username,
|
||||
})
|
||||
: formatMessage(messages.profileMetaDescription, { username: user.value.username })
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => title.value,
|
||||
description: () => description.value,
|
||||
ogTitle: () => title.value,
|
||||
ogDescription: () => description.value,
|
||||
ogImage: () => user.value.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
})
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
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(() => {
|
||||
let sum = 0
|
||||
|
||||
for (const project of projects.value) {
|
||||
sum += project.downloads
|
||||
}
|
||||
|
||||
return sum
|
||||
})
|
||||
const sumFollows = computed(() => {
|
||||
let sum = 0
|
||||
|
||||
for (const project of projects.value) {
|
||||
sum += project.followers
|
||||
}
|
||||
|
||||
return sum
|
||||
})
|
||||
|
||||
function cycleSearchDisplayMode() {
|
||||
cosmetics.value.searchDisplayMode.user = data.$cycleValue(
|
||||
cosmetics.value.searchDisplayMode.user,
|
||||
tags.value.projectViewModes
|
||||
)
|
||||
saveCosmetics()
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
export default defineNuxtComponent({
|
||||
methods: {},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.organizations-grid {
|
||||
// 5 wide
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.description {
|
||||
// Grow to take up remaining space
|
||||
flex-grow: 1;
|
||||
|
||||
color: var(--color-text);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.collection {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.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-lg);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-header-wrapper {
|
||||
display: flex;
|
||||
margin: 0 auto -1.5rem;
|
||||
max-width: 80rem;
|
||||
|
||||
.user-header {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-username {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 501px) {
|
||||
.mobile-username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-header-wrapper .user-header .username {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.sidebar__item:not(:last-child) {
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.profile-picture {
|
||||
border-radius: var(--size-rounded-lg);
|
||||
height: 8rem;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.bio {
|
||||
display: block;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.date {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inputs {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
input {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea-wrapper {
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.sidebar {
|
||||
padding-top: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
apps/frontend/src/pages/user/[id]/[projectType].vue
Normal file
1
apps/frontend/src/pages/user/[id]/[projectType].vue
Normal file
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
Reference in New Issue
Block a user