Migrate to Nuxt 3 (#933)

* Migrate to Nuxt 3

* Update vercel config

* remove tsconfig comment

* Changelog experiment + working proj pages

* Fix package json

* Prevent vercel complaining

* fix deploy (hopefully)

* Tag generator

* Switch to yarn

* Vercel pls 🙏

* Fix tag generation bug

* Make (most) non-logged in pages work

* fix base build

* Linting + state

* Eradicate axios, make most user pages work

* Fix checkbox state being set incorrectly

* Make most things work

* Final stretch

* Finish (most) things

* Move to update model value

* Fix modal text getting blurred from transforms (#964)

* Adjust nav-link border radius when focused (#961)

* Transition between animation states on TextLogo (#955)

* Transition between animation states on TextLogo

* Remove unused refs

* Fixes from review

* Disable tabbing to pagination arrows when disabled (#972)

* Make position of the "no results" text on grid/gallery views consistent (fixes #963) (#965)

* Fix position of the "no results" text on grid view

* fix padding

* Remove extra margin on main page, fixes #957 (#959)

* Fix layout shift and placeholder line height (#973)

* Fix a lot of issues

* Fix more nuxt 3 issues

* fix not all versions showing up (temp)

* inline inter css file

* More nuxt 3 fixes

* [skip ci] broken- backup changes

* Change modpack warnings to blue instead of red (#991)

* Fix some hydration issues

* Update nuxt

* Fix some images not showing

* Add pagination to versions page + fix lag

* Make changelog page consistent with versions page

* sync before merge

* Delete old file

* Fix actions failing

* update branch

* Fixes navbar transition animation. (#1012)

* Fixes navbar transition animation.

* Fixes Y-axis animation. Fixes mobile menu. Removes highlightjs prop.

* Changes xss call to renderString.

* Fixes renderString call.

* Removes unnecessary styling.

* Reverts mobile nav change.

* Nuxt 3 Lazy Loading Search (#1022)

* Uses lazyFetch for results. onSearchChange refreshes. Adds loading circle.

* Removes console.log

* Preserves old page when paging.

* Diagnosing filtering bugs.

* Fix single facet filtering

* Implements useAuth in settings/account.

* tiny ssr fix

* Updating nuxt.config checklist.

* Implements useAuth in revenue, moneitzation, and dashboard index pages.

* Fixes setups.

* Eliminates results when path changes. Adds animated logo.

* Ensures loading animation renders on search page.

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>

* Fix navigation issues

* Square button fix (#1023)

* Removes checklist from nuxt.config.

* Modifies Nuxt CI to build after linting.

* Fixes prettierignore file.

* bug fixes

* Update whitelist domains

* Page improvements, fix CLS

* Fix a lot of things

* Fix project type redirect

* Fix 404 errors

* Fix user settings + hydration error

* Final fixes

* fix(creator-section): border radius on icons not aligning with bg (#1027)

Co-authored-by: MagnusHJensen <magnus.holm.jensen@lego.dk>

* Improvements to the mobile navbar (#984)

* Transition between animation states on TextLogo

* Remove unused refs

* Fixes from review

* Improvements to the mobile nav menu

* fix avatar alt text

* Nevermind, got confused for a moment

* Tab bar, menu layout improvements

* Highlight search icon when menu is open

* Update layouts/default.vue

Co-authored-by: Magnus Jensen <magnushjensen.mail@gmail.com>

* Fix some issues

* Use caret instead

* Run prettier

* Add create a project

---------

Co-authored-by: Magnus Jensen <magnushjensen.mail@gmail.com>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>

* Fix mobile menu issues

* More issues

* Fix lint

---------

Co-authored-by: Kaeden Murphy <kmurphy@kaedenmurphy.dev>
Co-authored-by: triphora <emmaffle@modrinth.com>
Co-authored-by: Zach Baird <30800863+ZachBaird@users.noreply.github.com>
Co-authored-by: stairman06 <36215135+stairman06@users.noreply.github.com>
Co-authored-by: Zachary Baird <zdb1994@yahoo.com>
Co-authored-by: Magnus Jensen <magnushjensen.mail@gmail.com>
Co-authored-by: MagnusHJensen <magnus.holm.jensen@lego.dk>
This commit is contained in:
Geometrically
2023-03-09 10:05:32 -07:00
committed by GitHub
parent 5638f0f24b
commit 740357d120
145 changed files with 12371 additions and 37478 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
<template>
<div class="content">
<Head>
<Title> {{ project.title }} - Changelog </Title>
<Meta name="og:title" :content="`${props.project.title} - Changelog`" />
<Meta name="description" :content="metaDescription" />
<Meta name="apple-mobile-web-app-title" :content="`${props.project.title} - Changelog`" />
<Meta name="og:description" :content="metaDescription" />
</Head>
<VersionFilterControl
:versions="props.versions"
@update-versions="
(v) => {
filteredVersions = v
switchPage(1)
}
"
/>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<div class="card">
<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'
import { renderHighlightedString } from '~/helpers/highlight'
import VersionFilterControl from '~/components/ui/VersionFilterControl'
import Pagination from '~/components/ui/Pagination'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
})
const metaDescription = computed(
() => `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`
)
const route = useRoute()
const currentPage = ref(Number(route.query.p ?? 1))
const filteredVersions = shallowRef(props.versions)
async function switchPage(page) {
currentPage.value = page
const router = useRouter()
const route = useRoute()
await router.replace({
query: {
...route.query,
p: currentPage.value !== 1 ? currentPage.value : undefined,
},
})
}
</script>
<style lang="scss">
.changelog-item {
display: block;
margin-bottom: 1rem;
position: relative;
padding-left: 1.8rem;
.changelog-bar {
--color: var(--color-special-green);
&.alpha {
--color: var(--color-special-red);
}
&.release {
--color: var(--color-special-green);
}
&.beta {
--color: var(--color-special-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);
}
}
.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 {
display: none;
@media screen and (min-width: 800px) {
display: flex;
}
}
.markdown-body {
margin: 0.5rem 0.5rem 0 0;
}
}
}
</style>

View File

@@ -1,5 +1,12 @@
<template>
<div>
<Head>
<Title> {{ project.title }} - Gallery </Title>
<Meta name="og:title" :content="`${project.title} - Gallery`" />
<Meta name="description" :content="metaDescription" />
<Meta name="apple-mobile-web-app-title" :content="`${project.title} - Gallery`" />
<Meta name="og:description" :contcent="metaDescription" />
</Head>
<Modal
v-if="$auth.user && currentMember"
ref="modal_edit_item"
@@ -71,8 +78,8 @@
<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.
A featured gallery image shows up in search and your project card. Only one gallery
image can be featured.
</span>
</label>
<button
@@ -94,10 +101,7 @@
Unfeature image
</button>
<div class="button-group">
<button
class="iconified-button"
@click="$refs.modal_edit_item.hide()"
>
<button class="iconified-button" @click="$refs.modal_edit_item.hide()">
<CrossIcon />
Cancel
</button>
@@ -145,11 +149,7 @@
? expandedGalleryItem.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="
expandedGalleryItem.title
? expandedGalleryItem.title
: 'gallery-image'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop=""
/>
@@ -164,10 +164,7 @@
</div>
<div class="controls">
<div class="buttons">
<button
class="close circle-button"
@click="expandedGalleryItem = null"
>
<button class="close circle-button" @click="expandedGalleryItem = null">
<CrossIcon aria-hidden="true" />
</button>
<a
@@ -220,25 +217,21 @@
<DropArea :accept="acceptFileTypes" @change="handleFiles" />
</div>
<div class="items">
<div
v-for="(item, index) in project.gallery"
:key="index"
class="card gallery-item"
>
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
:src="
item.url
? item.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
: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>
<h2 v-if="item.title">
{{ item.title }}
</h2>
<p v-if="item.description">
{{ item.description }}
</p>
</div>
</div>
<div class="gallery-bottom">
@@ -250,13 +243,15 @@
<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()
() => {
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
}
"
>
<EditIcon />
@@ -265,8 +260,10 @@
<button
class="iconified-button"
@click="
deleteIndex = index
$refs.modal_confirm.show()
() => {
deleteIndex = index
$refs.modal_confirm.show()
}
"
>
<TrashIcon />
@@ -280,29 +277,29 @@
</template>
<script>
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?inline'
import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import ExternalIcon from '~/assets/images/utils/external.svg?inline'
import ExpandIcon from '~/assets/images/utils/expand.svg?inline'
import ContractIcon from '~/assets/images/utils/contract.svg?inline'
import StarIcon from '~/assets/images/utils/star.svg?inline'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import InfoIcon from '~/assets/images/utils/info.svg?inline'
import ImageIcon from '~/assets/images/utils/image.svg?inline'
import TransferIcon from '~/assets/images/utils/transfer.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg'
import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg'
import EditIcon from '~/assets/images/utils/edit.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import ExternalIcon from '~/assets/images/utils/external.svg'
import ExpandIcon from '~/assets/images/utils/expand.svg'
import ContractIcon from '~/assets/images/utils/contract.svg'
import StarIcon from '~/assets/images/utils/star.svg'
import UploadIcon from '~/assets/images/utils/upload.svg'
import InfoIcon from '~/assets/images/utils/info.svg'
import ImageIcon from '~/assets/images/utils/image.svg'
import TransferIcon from '~/assets/images/utils/transfer.svg'
import FileInput from '~/components/ui/FileInput'
import DropArea from '~/components/ui/DropArea'
import ModalConfirm from '~/components/ui/ModalConfirm'
import Modal from '~/components/ui/Modal'
export default {
export default defineNuxtComponent({
components: {
CalendarIcon,
PlusIcon,
@@ -325,7 +322,6 @@ export default {
FileInput,
DropArea,
},
auth: false,
props: {
project: {
type: Object,
@@ -356,36 +352,8 @@ export default {
editFile: null,
previewImage: null,
shouldPreventActions: false,
}
},
head() {
const title = `${this.project.title} - Gallery`
const description = `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
metaDescription: `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`,
}
},
computed: {
@@ -456,24 +424,30 @@ export default {
},
async createGalleryItem() {
this.shouldPreventActions = true
this.$nuxt.$loading.start()
startLoading()
try {
let url = `project/${this.project.id}/gallery?ext=${
this.editFile
? this.editFile.type.split('/')[
this.editFile.type.split('/').length - 1
]
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
: null
}&featured=${this.editFeatured}`
if (this.editTitle)
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
if (this.editDescription)
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
if (this.editOrder) url += `&ordering=${this.editOrder}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await this.$axios.post(url, this.editFile, this.$defaultHeaders())
await useBaseFetch(url, {
method: 'POST',
body: this.editFile,
...this.$defaultHeaders(),
})
await this.updateProject()
this.$refs.modal_edit_item.hide()
@@ -481,30 +455,37 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
text: err.data ? err.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
this.shouldPreventActions = false
},
async editGalleryItem() {
this.shouldPreventActions = true
this.$nuxt.$loading.start()
startLoading()
try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url
)}&featured=${this.editFeatured}`
if (this.editTitle)
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
if (this.editDescription)
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
if (this.editOrder) url += `&ordering=${this.editOrder}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await this.$axios.patch(url, {}, this.$defaultHeaders())
await useBaseFetch(url, {
method: 'PATCH',
...this.$defaultHeaders(),
})
await this.updateProject()
this.$refs.modal_edit_item.hide()
@@ -512,23 +493,26 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
text: err.data ? err.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
this.shouldPreventActions = false
},
async deleteGalleryImage() {
this.$nuxt.$loading.start()
startLoading()
try {
await this.$axios.delete(
await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url
)}`,
this.$defaultHeaders()
{
method: 'DELETE',
...this.$defaultHeaders(),
}
)
await this.updateProject()
@@ -536,19 +520,25 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
text: err.data ? err.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
async updateProject() {
await this.$parent.resetProject()
const project = await useBaseFetch(`project/${this.project.id}`, this.$defaultHeaders())
project.actualProjectType = JSON.parse(JSON.stringify(project.project_type))
project.project_type = this.$getProjectTypeForUrl(project.project_type, project.loaders)
this.$emit('update:project', project)
this.resetEdit()
},
},
}
})
</script>
<style lang="scss" scoped>
@@ -756,8 +746,7 @@ export default {
.gallery-bottom {
width: calc(100% - 2 * var(--spacing-card-md));
padding: 0 var(--spacing-card-md) var(--spacing-card-sm)
var(--spacing-card-md);
padding: 0 var(--spacing-card-md) var(--spacing-card-sm) var(--spacing-card-md);
.gallery-created {
display: flex;

View File

@@ -0,0 +1,21 @@
<template>
<div class="markdown-body card" v-html="renderHighlightedString(project.body)" />
</template>
<script>
import { renderHighlightedString } from '~/helpers/highlight'
export default defineNuxtComponent({
props: {
project: {
type: Object,
default() {
return {}
},
},
},
methods: { renderHighlightedString },
})
</script>
<style lang="scss" scoped></style>

View File

@@ -4,22 +4,19 @@
<label for="project-description">
<span class="label__title size-card-header">Description</span>
<span class="label__description">
You can type an extended description of your mod here. This editor
supports
You can type an extended description of your mod here. This editor supports
<a
class="text-link"
href="https://guides.github.com/features/mastering-markdown/"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
>Markdown</a
>. HTML can also be used inside your description, not including
styles, scripts, and iframes (though YouTube iframes are allowed).
>. HTML can also be used inside your description, not including styles, scripts, and
iframes (though YouTube iframes are allowed).
<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
>
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>
@@ -34,12 +31,9 @@
</div>
<div
v-else-if="bodyViewMode === 'preview'"
v-highlightjs
class="markdown-body"
v-html="
description ? $xss($md.render(description)) : 'No body specified.'
"
></div>
v-html="description ? renderHighlightedString(description) : 'No body specified.'"
/>
<div class="input-group">
<button
type="button"
@@ -57,10 +51,10 @@
<script>
import Chips from '~/components/ui/Chips'
import SaveIcon from '~/assets/images/utils/save.svg'
import { renderHighlightedString } from '~/helpers/highlight'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
export default {
export default defineNuxtComponent({
components: {
Chips,
SaveIcon,
@@ -100,13 +94,10 @@ export default {
},
data() {
return {
description: '',
description: this.project.body,
bodyViewMode: 'source',
}
},
fetch() {
this.description = this.project.body
},
computed: {
patchData() {
const data = {}
@@ -125,13 +116,14 @@ export default {
this.EDIT_BODY = 1 << 3
},
methods: {
renderHighlightedString,
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
}
})
</script>
<style lang="scss" scoped>
.resizable-textarea-wrapper textarea {

View File

@@ -20,9 +20,7 @@
</label>
<div class="input-group">
<Avatar
:src="
deletedIcon ? null : previewImage ? previewImage : project.icon_url
"
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
@@ -86,7 +84,7 @@
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
></textarea>
/>
</div>
<template
v-if="
@@ -101,9 +99,9 @@
<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.
{{ $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
@@ -111,9 +109,7 @@
v-model="clientSide"
placeholder="Select one"
:options="sideTypes"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
@@ -126,9 +122,9 @@
<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.
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Multiselect
@@ -136,9 +132,7 @@
v-model="serverSide"
placeholder="Select one"
:options="sideTypes"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
@@ -151,10 +145,9 @@
<label for="project-visibility">
<span class="label__title">Visibility</span>
<span class="label__description">
Set the visibility of your project. 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.
Set the visibility of your project. 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.
</span>
</label>
<Multiselect
@@ -190,8 +183,8 @@
</h3>
</div>
<p>
Removes your project from Modrinth's servers and search. Clicking on
this will delete your project, so be extra careful!
Removes your project from Modrinth's servers and search. Clicking on this will delete your
project, so be extra careful!
</p>
<button
type="button"
@@ -212,11 +205,11 @@ import Avatar from '~/components/ui/Avatar'
import ModalConfirm from '~/components/ui/ModalConfirm'
import FileInput from '~/components/ui/FileInput'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import UploadIcon from '~/assets/images/utils/upload.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
export default {
export default defineNuxtComponent({
components: {
Avatar,
ModalConfirm,
@@ -281,27 +274,19 @@ export default {
},
data() {
return {
name: '',
slug: '',
summary: '',
name: this.project.title,
slug: this.project.slug,
summary: this.project.description,
icon: null,
previewImage: null,
clientSide: '',
serverSide: '',
clientSide: this.project.client_side,
serverSide: this.project.server_side,
deletedIcon: false,
visibility: '',
visibility: this.$tag.approvedStatuses.includes(this.project.status)
? this.project.status
: this.project.requested_status,
}
},
fetch() {
this.name = this.project.title
this.slug = this.project.slug
this.summary = this.project.description
this.clientSide = this.project.client_side
this.serverSide = this.project.server_side
this.visibility = this.$tag.approvedStatuses.includes(this.project.status)
? this.project.status
: this.project.requested_status
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2
@@ -309,9 +294,7 @@ export default {
},
hasDeletePermission() {
const DELETE_PROJECT = 1 << 7
return (
(this.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
)
return (this.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
},
sideTypes() {
return ['required', 'optional', 'unsupported']
@@ -345,9 +328,7 @@ export default {
return data
},
hasChanges() {
return (
Object.keys(this.patchData).length > 0 || this.deletedIcon || this.icon
)
return Object.keys(this.patchData).length > 0 || this.deletedIcon || this.icon
},
},
methods: {
@@ -374,12 +355,12 @@ export default {
}
},
async deleteProject() {
await this.$axios.delete(
`project/${this.project.id}`,
this.$defaultHeaders()
)
await this.$store.dispatch('user/fetchProjects')
await this.$router.push(`/dashboard/projects`)
await useBaseFetch(`project/${this.project.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await initUserProjects()
await this.$router.push('/dashboard/projects')
this.$notify({
group: 'main',
title: 'Project deleted',
@@ -393,10 +374,10 @@ export default {
this.previewImage = null
},
async deleteIcon() {
await this.$axios.delete(
`project/${this.project.id}/icon`,
this.$defaultHeaders()
)
await useBaseFetch(`project/${this.project.id}/icon`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await this.updateIcon()
this.$notify({
group: 'main',
@@ -406,7 +387,7 @@ export default {
})
},
},
}
})
</script>
<style lang="scss" scoped>
.summary-input {

View File

@@ -6,34 +6,24 @@
<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"
>
{{ $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 noreferrer"
class="text-link"
>
<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.
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 noreferrer"
rel="noopener"
class="text-link"
>
licensing guide</a
@@ -62,6 +52,7 @@
v-if="license.requiresOnlyOrLater"
v-model="allowOrLater"
:disabled="!hasPermission"
description="Allow later editions of this license"
>
Allow later editions of this license
</Checkbox>
@@ -69,6 +60,7 @@
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>
@@ -110,9 +102,9 @@
<script>
import Multiselect from 'vue-multiselect'
import Checkbox from '~/components/ui/Checkbox'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg'
export default {
export default defineNuxtComponent({
components: {
Multiselect,
Checkbox,
@@ -154,28 +146,110 @@ export default {
showKnownErrors: false,
}
},
fetch() {
this.licenseUrl = this.project.license.url
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 licenseId = this.project.license.id
const licenseUrl = ref(props.project.license.url)
const licenseId = props.project.license.id
const trimmedLicenseId = licenseId
.replaceAll('-only', '')
.replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '')
this.license = this.defaultLicenses.find(
(x) => x.short === trimmedLicenseId
) ?? {
friendly: 'Custom',
short: licenseId.replaceAll('LicenseRef-', ''),
}
const license = ref(
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
friendly: 'Custom',
short: licenseId.replaceAll('LicenseRef-', ''),
}
)
if (licenseId === 'LicenseRef-Unknown') {
this.license = {
license.value = {
friendly: 'Unknown',
short: licenseId.replaceAll('LicenseRef-', ''),
}
}
this.allowOrLater = licenseId.includes('-or-later')
this.nonSpdxLicense = licenseId.includes('LicenseRef-')
const allowOrLater = computed(() => props.project.license.id.includes('-or-later'))
const nonSpdxLicense = computed(() => props.project.license.id.includes('LicenseRef-'))
return {
defaultLicenses,
licenseUrl,
license,
allowOrLater,
nonSpdxLicense,
}
},
computed: {
hasPermission() {
@@ -188,87 +262,18 @@ export default {
(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)
if (this.license.requiresOnlyOrLater) {
id += this.allowOrLater ? '-or-later' : '-only'
if (this.nonSpdxLicense && this.license.friendly === 'Custom')
}
if (this.nonSpdxLicense && this.license.friendly === 'Custom') {
id = id.replaceAll(' ', '-')
}
return id
},
defaultLicenses() {
return [
{ 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' },
]
},
patchData() {
const data = {}
@@ -292,6 +297,6 @@ export default {
}
},
},
}
})
</script>
<style lang="scss" scoped></style>

View File

@@ -9,8 +9,7 @@
>
<span class="label__title">Issue tracker</span>
<span class="label__description">
A place for users to report bugs, issues, and concerns about your
project.
A place for users to report bugs, issues, and concerns about your project.
</span>
</label>
<input
@@ -48,8 +47,7 @@
>
<span class="label__title">Wiki page</span>
<span class="label__description">
A page containing information, documentation, and help for the
project.
A page containing information, documentation, and help for the project.
</span>
</label>
<input
@@ -62,14 +60,9 @@
/>
</div>
<div class="adjacent-input">
<label
id="project-discord-invite"
title="An invitation link to your Discord server."
>
<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>
<span class="label__description"> An invitation link to your Discord server. </span>
</label>
<input
id="project-discord-invite"
@@ -100,7 +93,7 @@
:close-on-select="true"
:show-labels="false"
:disabled="!hasPermission"
@input="updateDonationLinks"
@update:model-value="updateDonationLinks"
/>
<input
v-model="donationLink.url"
@@ -128,9 +121,9 @@
<script>
import Multiselect from 'vue-multiselect'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg'
export default {
export default defineNuxtComponent({
components: {
Multiselect,
SaveIcon,
@@ -163,23 +156,22 @@ export default {
},
},
data() {
const donationLinks = JSON.parse(JSON.stringify(this.project.donation_urls))
donationLinks.push({
id: null,
platform: null,
url: null,
})
return {
issuesUrl: '',
sourceUrl: '',
wikiUrl: '',
discordUrl: '',
issuesUrl: this.project.issues_url,
sourceUrl: this.project.source_url,
wikiUrl: this.project.wiki_url,
discordUrl: this.project.discord_url,
donationLinks: [],
donationLinks,
}
},
fetch() {
this.issuesUrl = this.project.issues_url
this.sourceUrl = this.project.source_url
this.wikiUrl = this.project.wiki_url
this.discordUrl = this.project.discord_url
this.resetDonationLinks()
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2
@@ -198,13 +190,10 @@ export default {
data.wiki_url = this.wikiUrl === '' ? null : this.wikiUrl.trim()
}
if (this.checkDifference(this.discordUrl, this.project.discord_url)) {
data.discord_url =
this.discordUrl === '' ? null : this.discordUrl.trim()
data.discord_url = this.discordUrl === '' ? null : this.discordUrl.trim()
}
const donationLinks = this.donationLinks.filter(
(link) => link.url && link.platform
)
const donationLinks = this.donationLinks.filter((link) => link.url && link.platform)
donationLinks.forEach((link) => {
link.id = this.$tag.donationPlatforms.find(
(platform) => platform.name === link.platform
@@ -230,7 +219,12 @@ export default {
methods: {
async saveChanges() {
if (this.patchData && (await this.patchProject(this.patchData))) {
this.resetDonationLinks()
this.donationLinks = JSON.parse(JSON.stringify(this.project.donation_urls))
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
}
},
updateDonationLinks() {
@@ -258,16 +252,6 @@ export default {
})
}
},
resetDonationLinks() {
this.donationLinks = JSON.parse(
JSON.stringify(this.project.donation_urls)
)
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
},
checkDifference(newLink, existingLink) {
if (newLink === '' && existingLink !== null) {
return true
@@ -278,7 +262,7 @@ export default {
return newLink !== existingLink
},
},
}
})
</script>
<style lang="scss" scoped>
.donation-link-group {

View File

@@ -9,20 +9,15 @@
<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.
Enter the Modrinth username of the person you'd like to invite to be a member of this
project.
</span>
</span>
<div
v-if="(currentMember.permissions & MANAGE_INVITES) === MANAGE_INVITES"
class="input-group"
>
<input
id="username"
v-model="currentUsername"
type="text"
placeholder="Username"
/>
<input id="username" v-model="currentUsername" type="text" placeholder="Username" />
<label for="username" class="hidden">Username</label>
<button class="iconified-button brand-button" @click="inviteTeamMember">
<UserPlusIcon />
@@ -38,12 +33,7 @@
>
<div class="member-header">
<div class="info">
<Avatar
:src="member.avatar_url"
:alt="member.username"
size="sm"
circle
/>
<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>
@@ -59,9 +49,7 @@
@click="
openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id)
: (openTeamMembers = openTeamMembers.filter(
(it) => it !== member.user.id
))
: (openTeamMembers = openTeamMembers.filter((it) => it !== member.user.id))
"
>
<DropdownIcon />
@@ -81,37 +69,27 @@
v-model="allTeamMembers[index].role"
type="text"
:class="{ 'known-error': member.role === 'Owner' }"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/>
</div>
<div class="adjacent-input">
<label
:for="`member-${allTeamMembers[index].user.username}-monetization-weight`"
>
<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.
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="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/>
</div>
<p
v-if="member.role === 'Owner' && member.oldRole !== 'Owner'"
class="known-errors"
>
A project can only have one 'Owner'. Use the 'Transfer ownership'
button below if you no longer wish to be owner.
<p v-if="member.role === 'Owner' && member.oldRole !== 'Owner'" class="known-errors">
A project can only have one 'Owner'. Use the 'Transfer ownership' button below if you no
longer wish to be owner.
</p>
<template v-if="member.oldRole !== 'Owner'">
<span class="label">
@@ -119,102 +97,98 @@
</span>
<div class="permissions">
<Checkbox
:value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:model-value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
"
label="Upload version"
@input="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
@update:model-value="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
/>
<Checkbox
:value="(member.permissions & DELETE_VERSION) === DELETE_VERSION"
:model-value="(member.permissions & DELETE_VERSION) === DELETE_VERSION"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_VERSION) !== DELETE_VERSION
"
label="Delete version"
@input="allTeamMembers[index].permissions ^= DELETE_VERSION"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_VERSION"
/>
<Checkbox
:value="(member.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:model-value="(member.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
label="Edit details"
@input="allTeamMembers[index].permissions ^= EDIT_DETAILS"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_DETAILS"
/>
<Checkbox
:value="(member.permissions & EDIT_BODY) === EDIT_BODY"
:model-value="(member.permissions & EDIT_BODY) === EDIT_BODY"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_BODY) !== EDIT_BODY
"
label="Edit body"
@input="allTeamMembers[index].permissions ^= EDIT_BODY"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_BODY"
/>
<Checkbox
:value="(member.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:model-value="(member.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & MANAGE_INVITES) !== MANAGE_INVITES
"
label="Manage invites"
@input="allTeamMembers[index].permissions ^= MANAGE_INVITES"
@update:model-value="allTeamMembers[index].permissions ^= MANAGE_INVITES"
/>
<Checkbox
:value="(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:model-value="(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
"
label="Remove member"
@input="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
@update:model-value="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
/>
<Checkbox
:value="(member.permissions & EDIT_MEMBER) === EDIT_MEMBER"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
:model-value="(member.permissions & EDIT_MEMBER) === EDIT_MEMBER"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
label="Edit member"
@input="allTeamMembers[index].permissions ^= EDIT_MEMBER"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_MEMBER"
/>
<Checkbox
:value="(member.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:model-value="(member.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT
"
label="Delete project"
@input="allTeamMembers[index].permissions ^= DELETE_PROJECT"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_PROJECT"
/>
<Checkbox
:value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:model-value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
"
label="View analytics"
@input="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
/>
<Checkbox
:value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:model-value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
"
label="View revenue"
@input="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
/>
</div>
</template>
<div class="input-group">
<button
class="iconified-button brand-button"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="updateTeamMember(index)"
>
<SaveIcon />
@@ -223,20 +197,14 @@
<button
v-if="member.oldRole !== 'Owner'"
class="iconified-button danger-button"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="removeTeamMember(index)"
>
<UserRemoveIcon />
Remove member
</button>
<button
v-if="
member.oldRole !== 'Owner' &&
currentMember.role === 'Owner' &&
member.accepted
"
v-if="member.oldRole !== 'Owner' && currentMember.role === 'Owner' && member.accepted"
class="iconified-button"
@click="transferOwnership(index)"
>
@@ -253,14 +221,14 @@
import Checkbox from '~/components/ui/Checkbox'
import Badge from '~/components/ui/Badge'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import TransferIcon from '~/assets/images/utils/transfer.svg?inline'
import UserPlusIcon from '~/assets/images/utils/user-plus.svg?inline'
import UserRemoveIcon from '~/assets/images/utils/user-x.svg?inline'
import DropdownIcon from '~/assets/images/utils/dropdown.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import TransferIcon from '~/assets/images/utils/transfer.svg'
import UserPlusIcon from '~/assets/images/utils/user-plus.svg'
import UserRemoveIcon from '~/assets/images/utils/user-x.svg'
import Avatar from '~/components/ui/Avatar'
export default {
export default defineNuxtComponent({
components: {
Avatar,
DropdownIcon,
@@ -295,14 +263,12 @@ export default {
return {
currentUsername: '',
openTeamMembers: [],
allTeamMembers: [],
allTeamMembers: this.allMembers.map((x) => {
x.oldRole = x.role
return x
}),
}
},
fetch() {
this.allTeamMembers = this.allMembers
this.allTeamMembers.forEach((x) => (x.oldRole = x.role))
},
created() {
this.UPLOAD_VERSION = 1 << 0
this.DELETE_VERSION = 1 << 1
@@ -317,55 +283,57 @@ export default {
},
methods: {
async inviteTeamMember() {
this.$nuxt.$loading.start()
startLoading()
try {
const user = (await this.$axios.get(`user/${this.currentUsername}`))
.data
const user = await useBaseFetch(`user/${this.currentUsername}`)
const data = {
user_id: user.id.trim(),
}
await this.$axios.post(
`team/${this.project.team}/members`,
data,
this.$defaultHeaders()
)
await useBaseFetch(`team/${this.project.team}/members`, {
method: 'POST',
body: data,
...this.$defaultHeaders(),
})
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
async removeTeamMember(index) {
this.$nuxt.$loading.start()
startLoading()
try {
await this.$axios.delete(
await useBaseFetch(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
this.$defaultHeaders()
{
method: 'DELETE',
...this.$defaultHeaders(),
}
)
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
async updateTeamMember(index) {
this.$nuxt.$loading.start()
startLoading()
try {
const data =
@@ -379,59 +347,59 @@ export default {
payouts_split: this.allTeamMembers[index].payouts_split,
}
await this.$axios.patch(
await useBaseFetch(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
data,
this.$defaultHeaders()
{
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
}
)
await this.updateMembers()
this.$notify({
group: 'main',
title: 'Member(s) updated',
text: `Your project's member(s) has been updated.`,
text: "Your project's member(s) has been updated.",
type: 'success',
})
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
async transferOwnership(index) {
this.$nuxt.$loading.start()
startLoading()
try {
await this.$axios.patch(
`team/${this.project.team}/owner`,
{
await useBaseFetch(`team/${this.project.team}/owner`, {
method: 'PATCH',
body: {
user_id: this.allTeamMembers[index].user.id,
},
this.$defaultHeaders()
)
...this.$defaultHeaders(),
})
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
async updateMembers() {
this.allTeamMembers = (
await this.$axios.get(
`team/${this.project.team}/members`,
this.$defaultHeaders()
)
).data.map((it) => ({
await useBaseFetch(`team/${this.project.team}/members`, this.$defaultHeaders())
).map((it) => ({
avatar_url: it.user.avatar_url,
name: it.user.username,
oldRole: it.role,
@@ -439,7 +407,7 @@ export default {
}))
},
},
}
})
</script>
<style lang="scss" scoped>

View File

@@ -8,15 +8,13 @@
</div>
<p>
Accurate tagging is important to help people find your
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure
to select all tags that apply.
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<template v-for="header in Object.keys(categoryLists)">
<div :key="`categories-${header}`" class="label">
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div class="label">
<h4>
<span class="label__title">{{
$formatCategoryHeader(header)
}}</span>
<span class="label__title">{{ $formatCategoryHeader(header) }}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
@@ -25,8 +23,7 @@
</template>
<template v-else-if="header === 'features'">
Select all of the features that your
{{ $formatProjectType(project.project_type).toLowerCase() }} makes
use of.
{{ $formatProjectType(project.project_type).toLowerCase() }} makes use of.
</template>
<template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your
@@ -34,21 +31,20 @@
</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.
{{ $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 :key="`categories-${header}-list`" class="category-list input-div">
<div class="category-list input-div">
<Checkbox
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:value="selectedTags.includes(category)"
:model-value="selectedTags.includes(category)"
:description="$formatCategory(category.name)"
class="category-selector"
@input="toggleCategory(category)"
@update:model-value="toggleCategory(category)"
>
<div class="category-selector__label">
<div
@@ -56,10 +52,8 @@
aria-hidden="true"
class="icon"
v-html="category.icon"
></div>
<span aria-hidden="true">
{{ $formatCategory(category.name) }}</span
>
/>
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
@@ -69,8 +63,8 @@
<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.
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">
@@ -81,12 +75,10 @@
v-for="category in selectedTags"
:key="`featured-category-${category.name}`"
class="category-selector"
:value="featuredTags.includes(category)"
:model-value="featuredTags.includes(category)"
:description="$formatCategory(category.name)"
:disabled="
featuredTags.length >= 3 && !featuredTags.includes(category)
"
@input="toggleFeaturedCategory(category)"
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
@update:model-value="toggleFeaturedCategory(category)"
>
<div class="category-selector__label">
<div
@@ -94,10 +86,8 @@
aria-hidden="true"
class="icon"
v-html="category.icon"
></div>
<span aria-hidden="true">
{{ $formatCategory(category.name) }}</span
>
/>
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
@@ -118,10 +108,10 @@
<script>
import Checkbox from '~/components/ui/Checkbox'
import StarIcon from '~/assets/images/utils/star.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import StarIcon from '~/assets/images/utils/star.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
export default {
export default defineNuxtComponent({
components: {
Checkbox,
SaveIcon,
@@ -162,23 +152,19 @@ export default {
},
data() {
return {
selectedTags: [],
featuredTags: [],
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)
),
}
},
fetch() {
this.selectedTags = this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name))
)
this.featuredTags = this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name)
)
},
computed: {
categoryLists() {
const lists = {}
@@ -197,19 +183,11 @@ export default {
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)
)
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)
)
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x))
}
// Convert selected and featured categories to backend-usable arrays
@@ -226,11 +204,8 @@ export default {
}
if (
additionalCategories.length !==
this.project.additional_categories.length ||
additionalCategories.some(
(value) => !this.project.additional_categories.includes(value)
)
additionalCategories.length !== this.project.additional_categories.length ||
additionalCategories.some((value) => !this.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories
}
@@ -265,7 +240,7 @@ export default {
}
},
},
}
})
</script>
<style lang="scss" scoped>
.label__title {
@@ -281,7 +256,7 @@ export default {
column-gap: var(--spacing-card-lg);
margin-bottom: var(--spacing-card-md);
.category-selector ::v-deep {
:deep(.category-selector) {
margin-bottom: 0.5rem;
.category-selector__label {
display: flex;

View File

@@ -0,0 +1,8 @@
<template>
<div />
</template>
<script setup>
definePageMeta({
middleware: 'auth',
})
</script>

View File

@@ -1,5 +1,12 @@
<template>
<div class="content">
<Head>
<Title> {{ project.title }} - Versions </Title>
<Meta name="og:title" :content="`${project.title} - Versions`" />
<Meta name="description" :content="metaDescription" />
<Meta name="apple-mobile-web-app-title" :content="`${project.title} - Versions`" />
<Meta name="og:description" :content="metaDescription" />
</Head>
<div v-if="currentMember" class="card header-buttons">
<FileInput
:max-size="524288000"
@@ -13,25 +20,33 @@
<span class="indicator">
<InfoIcon /> Click to choose a file or drag one onto this page
</span>
<DropArea
:accept="acceptFileFromProjectType(project.project_type)"
@change="handleFiles"
/>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<VersionFilterControl
class="card"
:versions="versions"
@updateVersions="updateVersions"
:versions="props.versions"
@update-versions="
(v) => {
filteredVersions = v
switchPage(1)
}
"
/>
<div v-if="versions.length > 0" class="universal-card all-versions">
<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" class="universal-card all-versions">
<div class="header">
<div></div>
<div />
<div>Version</div>
<div>Supports</div>
<div>Stats</div>
</div>
<div
v-for="version in filteredVersions"
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="version-button button-transparent"
@click="
@@ -44,15 +59,12 @@
>
<a
v-tooltip="
$parent.findPrimary(version).filename +
' (' +
$formatBytes($parent.findPrimary(version).size) +
')'
version.primaryFile.filename + ' (' + $formatBytes(version.primaryFile.size) + ')'
"
:href="$parent.findPrimary(version).url"
:href="version.primaryFile.url"
class="download-button square-button brand-button"
:class="version.version_type"
:title="`Download ${version.name}`"
:aria-label="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()"
>
<DownloadIcon aria-hidden="true" />
@@ -64,24 +76,11 @@
class="version__title"
>
{{ version.name }}
<FeaturedIcon v-if="featuredVersionIds.includes(version.id)" />
</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"
/>
<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>
@@ -98,130 +97,99 @@
</span>
<span>
Published on
<strong>{{
$dayjs(version.date_published).format('MMM D, YYYY')
}}</strong>
<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>
import { acceptFileFromProjectType } from '~/plugins/fileUtils'
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import InfoIcon from '~/assets/images/utils/info.svg?inline'
import FeaturedIcon from '~/assets/images/utils/star.svg?inline'
<script setup>
import { acceptFileFromProjectType } from '~/helpers/fileUtils'
import DownloadIcon from '~/assets/images/utils/download.svg'
import UploadIcon from '~/assets/images/utils/upload.svg'
import InfoIcon from '~/assets/images/utils/info.svg'
import VersionBadge from '~/components/ui/Badge'
import FileInput from '~/components/ui/FileInput'
import DropArea from '~/components/ui/DropArea'
import Pagination from '~/components/ui/Pagination'
import VersionFilterControl from '~/components/ui/VersionFilterControl'
import DropArea from '~/components/ui/DropArea.vue'
export default {
components: {
DropArea,
DownloadIcon,
UploadIcon,
InfoIcon,
FeaturedIcon,
VersionBadge,
VersionFilterControl,
FileInput,
},
auth: false,
props: {
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
featuredVersions: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
data() {
return {
filteredVersions: this.versions,
}
versions: {
type: Array,
default() {
return []
},
},
fetch() {
if (this.$route.query.page)
this.currentPage = parseInt(this.$route.query.page)
members: {
type: Array,
default() {
return []
},
},
head() {
const title = `${this.project.title} - Versions`
const description = `Download and browse ${this.versions.length} ${
this.project.title
} versions. ${this.$formatNumber(
this.project.downloads
)} total downloads. Last updated ${this.$dayjs(
this.versions[0] ? this.versions[0].date_published : null
).format('MMM D, YYYY')}.`
currentMember: {
type: Object,
default() {
return {}
},
},
})
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
}
},
computed: {
featuredVersionIds() {
return this.featuredVersions.map((x) => x.id)
const data = useNuxtApp()
const metaDescription = computed(
() =>
`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')}.`
)
const route = useRoute()
const currentPage = ref(Number(route.query.p ?? 1))
const filteredVersions = shallowRef(props.versions)
async function switchPage(page) {
currentPage.value = page
const router = useRouter()
const route = useRoute()
await router.replace({
query: {
...route.query,
p: currentPage.value !== 1 ? currentPage.value : undefined,
},
},
methods: {
acceptFileFromProjectType,
updateVersions(updatedVersions) {
this.filteredVersions = updatedVersions
})
}
async function handleFiles(files) {
const router = useRouter()
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',
},
async handleFiles(files) {
await this.$router.push({
name: 'type-id-version-create',
params: {
type: this.project.project_type,
id: this.project.slug ? this.project.slug : this.project.id,
newPrimaryFile: files[0],
},
})
state: {
newPrimaryFile: files[0],
},
},
})
}
</script>
@@ -246,7 +214,7 @@ export default {
.header {
display: grid;
grid-template: 'download title supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr 1fr 1fr;
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;
@@ -278,7 +246,7 @@ export default {
'download title supports stats'
'download metadata supports stats'
'download dummy supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr 1fr 1fr;
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);
@@ -354,13 +322,17 @@ export default {
}
}
.modal-create {
padding: var(--spacing-card-bg);
.input-group {
width: fit-content;
margin-left: auto;
margin-top: 1.5rem;
.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>

View File

@@ -1,266 +0,0 @@
<template>
<div class="content">
<VersionFilterControl
class="card"
:versions="versions"
@updateVersions="updateVersions"
/>
<div class="card">
<div
v-for="version in filteredVersions"
:key="version.id"
class="changelog-item"
>
<div
:class="`changelog-bar ${version.version_type} ${
version.duplicate ? 'duplicate' : ''
}`"
></div>
<div class="version-wrapper">
<div class="version-header">
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>{{ version.name }}</nuxt-link
>
</h2>
<span v-if="members.find((x) => x.user.id === version.author_id)">
by
<nuxt-link
class="text-link"
:to="
'/user/' +
members.find((x) => x.user.id === version.author_id).user
.username
"
>{{
members.find((x) => x.user.id === version.author_id).user
.username
}}</nuxt-link
>
</span>
<span>
on
{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
>
</div>
<a
:href="$parent.findPrimary(version).url"
class="iconified-button download"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
v-highlightjs
class="markdown-body"
v-html="$xss($md.render(version.changelog))"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
import VersionFilterControl from '~/components/ui/VersionFilterControl'
export default {
components: {
VersionFilterControl,
DownloadIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
},
data() {
return {
filteredVersions: this.$calculateDuplicates(this.versions),
currentPage: 1,
}
},
fetch() {
if (this.$route.query.page) {
this.currentPage = parseInt(this.$route.query.page)
}
},
head() {
const title = `${this.project.title} - Changelog`
const description = `Explore the changelog of ${this.project.title}'s ${this.versions.length} versions.`
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
}
},
methods: {
async switchPage(page, toTop) {
this.currentPage = page
await this.$router.replace(this.getPageLink(page))
if (toTop) {
setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 50)
setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 50)
}
},
getPageLink(page) {
if (page === 1) {
return this.$route.path
} else {
return `${this.$route.path}?page=${this.currentPage}`
}
},
updateVersions(updatedVersions) {
this.filteredVersions = this.$calculateDuplicates(updatedVersions)
},
},
auth: false,
}
</script>
<style lang="scss" scoped>
.changelog-item {
display: block;
margin-bottom: 1rem;
position: relative;
padding-left: 1.8rem;
.changelog-bar {
--color: var(--color-special-green);
&.alpha {
--color: var(--color-special-red);
}
&.release {
--color: var(--color-special-green);
}
&.beta {
--color: var(--color-special-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);
}
}
}
.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 {
display: none;
@media screen and (min-width: 800px) {
display: flex;
}
}
}
.markdown-body {
margin: 0.5rem 0.5rem 0 0;
}
</style>

View File

@@ -1,23 +0,0 @@
<template>
<div
v-highlightjs
class="markdown-body card"
v-html="$xss($md.render(project.body))"
></div>
</template>
<script>
export default {
auth: false,
props: {
project: {
type: Object,
default() {
return {}
},
},
},
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,8 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,10 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
auth: false,
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,8 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,10 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
auth: false,
}
</script>
<style lang="scss" scoped></style>

View File

@@ -13,49 +13,27 @@
<!-- <NavStackItem link="/dashboard/analytics" label="Analytics">-->
<!-- <ChartIcon />-->
<!-- </NavStackItem>-->
<NavStackItem
v-if="hasMonetization()"
link="/dashboard/revenue"
label="Revenue"
>
<NavStackItem link="/dashboard/revenue" label="Revenue">
<CurrencyIcon />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtChild />
<NuxtPage />
</div>
</div>
</template>
<script>
<script setup>
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import DashboardIcon from '~/assets/images/utils/dashboard.svg?inline'
// import ChartIcon from '~/assets/images/utils/chart.svg?inline'
import CurrencyIcon from '~/assets/images/utils/currency.svg?inline'
import ListIcon from '~/assets/images/utils/list.svg?inline'
import DashboardIcon from '~/assets/images/utils/dashboard.svg'
import CurrencyIcon from '~/assets/images/utils/currency.svg'
import ListIcon from '~/assets/images/utils/list.svg'
const monetization = true
export default {
name: 'Dashboard',
components: {
NavStack,
NavStackItem,
DashboardIcon,
// ChartIcon,
CurrencyIcon,
ListIcon,
},
methods: {
hasMonetization() {
return monetization
},
},
}
definePageMeta({
middleware: 'auth',
})
</script>
<style lang="scss" scoped></style>

View File

@@ -3,24 +3,16 @@
<section class="universal-card">
<h2>Analytics</h2>
<p>You found a secret!</p>
<nuxt-link to="/frog" class="goto-link"
>Click here for fancy graphs!</nuxt-link
>
<nuxt-link to="/frog" class="goto-link"> Click here for fancy graphs! </nuxt-link>
</section>
</div>
</template>
<script>
export default {
components: {},
data() {
return {}
},
fetch() {},
export default defineNuxtComponent({
head: {
title: 'Analytics - Modrinth',
},
methods: {},
}
})
</script>
<style lang="scss" scoped></style>

View File

@@ -6,11 +6,7 @@
<div class="grid-display__item">
<div class="label">Total downloads</div>
<div class="value">
{{
$formatNumber(
$user.projects.reduce((agg, x) => agg + x.downloads, 0)
)
}}
{{ $formatNumber(user.projects.reduce((agg, x) => agg + x.downloads, 0)) }}
</div>
<span
>from
@@ -27,11 +23,7 @@
<div class="grid-display__item">
<div class="label">Total followers</div>
<div class="value">
{{
$formatNumber(
$user.projects.reduce((agg, x) => agg + x.followers, 0)
)
}}
{{ $formatNumber(user.projects.reduce((agg, x) => agg + x.followers, 0)) }}
</div>
<span>
<span
@@ -49,7 +41,9 @@
</div>
<div class="grid-display__item">
<div class="label">Total revenue</div>
<div class="value">{{ $formatMoney(payouts.all_time) }}</div>
<div class="value">
{{ $formatMoney(payouts.all_time) }}
</div>
<span>{{ $formatMoney(payouts.last_month) }} this month</span>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown-->
@@ -61,17 +55,16 @@
<div class="grid-display__item">
<div class="label">Current balance</div>
<div class="value">
{{ $formatMoney($auth.user.payout_data.balance) }}
{{ $formatMoney(auth.user.payout_data.balance) }}
</div>
<NuxtLink
v-if="$auth.user.payout_data.balance >= minWithdraw"
v-if="auth.user.payout_data.balance >= minWithdraw"
class="goto-link"
to="/dashboard/revenue"
>Withdraw earnings
<ChevronRightIcon
class="featured-header-chevron"
aria-hidden="true"
/></NuxtLink>
>
Withdraw earnings
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
</NuxtLink>
<span v-else>${{ minWithdraw }} is the withdraw minimum</span>
</div>
</div>
@@ -79,55 +72,37 @@
<section class="universal-card more-soon">
<h2>More coming soon!</h2>
<p>
Stay tuned for more metrics and analytics (pretty graphs, anyone? 👀)
coming to the creators dashboard soon!
Stay tuned for more metrics and analytics (pretty graphs, anyone? 👀) coming to the creators
dashboard soon!
</p>
</section>
</div>
</template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
<script>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline'
useHead({
title: 'Creator dashboard - Modrinth',
})
export default {
components: { ChevronRightIcon },
async asyncData(data) {
const [payouts] = (
await Promise.all([
data.$axios.get(
`user/${data.$auth.user.id}/payouts`,
data.$defaultHeaders()
),
])
).map((it) => it.data)
const auth = await useAuth()
const app = useNuxtApp()
payouts.all_time = Math.floor(payouts.all_time * 100) / 100
payouts.last_month = Math.floor(payouts.last_month * 100) / 100
const [raw] = await Promise.all([
useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders()),
])
const user = await useUser()
return {
payouts,
}
},
data() {
return {
minWithdraw: 0.26,
}
},
fetch() {},
head: {
title: 'Creator dashboard - Modrinth',
},
computed: {
downloadsProjectCount() {
return this.$user.projects.filter((project) => project.downloads > 0)
.length
},
followersProjectCount() {
return this.$user.projects.filter((project) => project.followers > 0)
.length
},
},
methods: {},
}
raw.all_time = Math.floor(raw.all_time * 100) / 100
raw.last_month = Math.floor(raw.last_month * 100) / 100
const payouts = ref(raw)
const minWithdraw = ref(0.26)
const downloadsProjectCount = computed(
() => user.value.projects.filter((project) => project.downloads > 0).length
)
const followersProjectCount = computed(
() => user.value.projects.filter((project) => project.followers > 0).length
)
</script>
<style lang="scss" scoped></style>

View File

@@ -3,9 +3,9 @@
<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.
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
@@ -21,14 +21,13 @@
:disabled="editLinks.issues.clear"
type="url"
:placeholder="
editLinks.issues.clear
? 'Existing link will be cleared'
: 'Enter a valid URL'
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"
@@ -50,13 +49,12 @@
type="url"
maxlength="2048"
:placeholder="
editLinks.source.clear
? 'Existing link will be cleared'
: 'Enter a valid URL'
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"
@@ -78,13 +76,12 @@
type="url"
maxlength="2048"
:placeholder="
editLinks.wiki.clear
? 'Existing link will be cleared'
: 'Enter a valid URL'
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"
@@ -92,10 +89,7 @@
<TrashIcon />
</button>
</div>
<label
for="discord-invite-input"
title="An invitation link to your Discord server."
>
<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">
@@ -113,6 +107,7 @@
/>
<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"
@@ -154,10 +149,7 @@
<CrossIcon />
Cancel
</button>
<button
class="iconified-button brand-button"
@click="bulkEditLinks()"
>
<button class="iconified-button brand-button" @click="bulkEditLinks()">
<SaveIcon />
Save changes
</button>
@@ -169,10 +161,7 @@
<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()"
>
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<PlusIcon />
Create a project
</button>
@@ -203,8 +192,8 @@
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@input="updateSort()"
></Multiselect>
@update:model-value="projects = updateSort(projects, sortBy)"
/>
</div>
</div>
</div>
@@ -212,8 +201,8 @@
<div class="grid-table__row grid-table__header">
<div>
<Checkbox
:value="selectedProjects === projects"
@input="
:model-value="selectedProjects === projects"
@update:model-value="
selectedProjects === projects
? (selectedProjects = [])
: (selectedProjects = projects)
@@ -225,33 +214,22 @@
<div>ID</div>
<div>Type</div>
<div>Status</div>
<div></div>
<div />
</div>
<div
v-for="project in projects"
:key="`project-${project.id}`"
class="grid-table__row"
>
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
<div>
<Checkbox
:disabled="
(project.permissions & EDIT_DETAILS) === EDIT_DETAILS
"
:value="selectedProjects.includes(project)"
@input="
: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 = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
/>
</div>
<div>
<nuxt-link
tabindex="-1"
:to="`/${project.project_type}/${project.slug}`"
>
<nuxt-link tabindex="-1" :to="`/${project.project_type}/${project.slug}`">
<Avatar
:src="project.icon_url"
aria-hidden="true"
@@ -265,9 +243,6 @@
<span class="project-title">
<IssuesIcon
v-if="project.moderator_message"
v-tooltip="
'Project has a message from the moderators. View the project to see more.'
"
aria-label="Project has a message from the moderators. View the project to see more."
/>
@@ -285,15 +260,11 @@
</div>
<div>
{{ $formatProjectType(project.project_type) }}
{{ $formatProjectType($getProjectTypeForUrl(project.project_type, project.loaders)) }}
</div>
<div>
<Badge
v-if="project.status"
:type="project.status"
class="status"
/>
<Badge v-if="project.status" :type="project.status" class="status" />
</div>
<div>
@@ -317,20 +288,19 @@ 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 ModalConfirm from '~/components/ui/ModalConfirm.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?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import IssuesIcon from '~/assets/images/utils/issues.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import IssuesIcon from '~/assets/images/utils/issues.svg'
import PlusIcon from '~/assets/images/utils/plus.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import EditIcon from '~/assets/images/utils/edit.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
export default {
export default defineNuxtComponent({
components: {
Avatar,
Badge,
@@ -343,14 +313,21 @@ export default {
EditIcon,
SaveIcon,
Modal,
// ModalConfirm,
ModalCreation,
Multiselect,
CopyCode,
},
async setup() {
const user = await useUser()
if (process.client) {
await initUserProjects()
}
return { user: ref(user) }
},
data() {
return {
projects: [],
projects: this.updateSort(this.user.projects, 'Name'),
versions: [],
selectedProjects: [],
sortBy: 'Name',
@@ -375,10 +352,6 @@ export default {
},
}
},
fetch() {
this.projects = this.$user.projects
this.updateSort()
},
head: {
title: 'Projects - Modrinth',
},
@@ -392,12 +365,11 @@ export default {
this.EDIT_MEMBER = 1 << 6
this.DELETE_PROJECT = 1 << 7
},
mounted() {},
methods: {
updateSort() {
switch (this.sortBy) {
updateSort(projects, sort) {
switch (sort) {
case 'Name':
this.projects = this.projects.slice().sort((a, b) => {
return projects.slice().sort((a, b) => {
if (a.title < b.title) {
return -1
}
@@ -406,9 +378,8 @@ export default {
}
return 0
})
break
case 'Status':
this.projects = this.projects.slice().sort((a, b) => {
return projects.slice().sort((a, b) => {
if (a.status < b.status) {
return -1
}
@@ -417,9 +388,8 @@ export default {
}
return 0
})
break
case 'Type':
this.projects = this.projects.slice().sort((a, b) => {
return projects.slice().sort((a, b) => {
if (a.project_type < b.project_type) {
return -1
}
@@ -428,7 +398,6 @@ export default {
}
return 0
})
break
default:
break
}
@@ -437,13 +406,11 @@ export default {
try {
const baseData = {
issues_url:
!this.editLinks.issues.clear &&
this.editLinks.issues.val.trim() !== ''
!this.editLinks.issues.clear && this.editLinks.issues.val.trim() !== ''
? this.editLinks.issues.val
: null,
source_url:
!this.editLinks.source.clear &&
this.editLinks.source.val.trim() !== ''
!this.editLinks.source.clear && this.editLinks.source.val.trim() !== ''
? this.editLinks.source.val
: null,
wiki_url:
@@ -451,18 +418,18 @@ export default {
? this.editLinks.wiki.val
: null,
discord_url:
!this.editLinks.discord.clear &&
this.editLinks.discord.val.trim() !== ''
!this.editLinks.discord.clear && this.editLinks.discord.val.trim() !== ''
? this.editLinks.discord.val
: null,
}
await this.$axios.patch(
`projects?ids=${JSON.stringify(
this.selectedProjects.map((x) => x.id)
)}`,
baseData,
this.$defaultHeaders()
await useBaseFetch(
`projects?ids=${JSON.stringify(this.selectedProjects.map((x) => x.id))}`,
{
method: 'PATCH',
body: baseData,
...this.$defaultHeaders(),
}
)
this.$refs.editLinksModal.hide()
@@ -483,7 +450,7 @@ export default {
}
},
},
}
})
</script>
<style lang="scss" scoped>
.grid-table {

View File

@@ -3,47 +3,42 @@
<ModalTransfer
v-if="enrolled"
ref="modal_transfer"
:wallet="$auth.user.payout_data.payout_wallet"
:account-type="$auth.user.payout_data.payout_wallet_type"
:account="$auth.user.payout_data.payout_address"
:balance="$auth.user.payout_data.balance"
:wallet="auth.user.payout_data.payout_wallet"
:account-type="auth.user.payout_data.payout_wallet_type"
:account="auth.user.payout_data.payout_address"
:balance="auth.user.payout_data.balance"
:min-withdraw="minWithdraw"
/>
<section class="universal-card">
<h2>Withdraw</h2>
<div v-if="$auth.user.payout_data.balance >= minWithdraw">
<div v-if="auth.user.payout_data.balance >= minWithdraw">
<p>
You have
<strong>{{ $formatMoney($auth.user.payout_data.balance) }}</strong>
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong>
available to withdraw.
<span v-if="!enrolled"
>Enroll in the Creator Monetization Program to withdraw your
revenue.</span
>Enroll in the Creator Monetization Program to withdraw your revenue.</span
>
</p>
<div v-if="enrolled" class="input-group">
<button
class="iconified-button brand-button"
@click="$refs.modal_transfer.show()"
>
<button class="iconified-button brand-button" @click="$refs.modal_transfer.show()">
<TransferIcon /> Transfer to
{{ $formatWallet($auth.user.payout_data.payout_wallet) }}
{{ $formatWallet(auth.user.payout_data.payout_wallet) }}
</button>
<NuxtLink class="iconified-button" to="/settings/monetization">
<SettingsIcon /> Monetization settings
</NuxtLink>
</div>
</div>
<p v-else-if="$auth.user.payout_data.balance > 0">
<p v-else-if="auth.user.payout_data.balance > 0">
You have made
<strong>{{ $formatMoney($auth.user.payout_data.balance) }}</strong
>, however you have not yet met the minimum of ${{ minWithdraw }} to
withdraw.
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong
>, however you have not yet met the minimum of ${{ minWithdraw }} to withdraw.
</p>
<p v-else>
You have made
<strong>{{ $formatMoney($auth.user.payout_data.balance) }}</strong
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
</p>
<div v-if="!enrolled">
@@ -55,9 +50,9 @@
<section class="universal-card">
<h2>Processing fees</h2>
<p>
To avoid paying unnecessary fee deductions, you may want to wait to
transfer your money out after it accumulates for a bit rather than
transferring as soon as you reach the minimum of ${{ minWithdraw }}.
To avoid paying unnecessary fee deductions, you may want to wait to transfer your money out
after it accumulates for a bit rather than transferring as soon as you reach the minimum of
${{ minWithdraw }}.
</p>
<h3>PayPal</h3>
<ul>
@@ -67,55 +62,57 @@
fee per transaction.
</li>
<li>
In the rest of the world, PayPal charges a <strong>2%</strong> (up to
$20) fee per transaction.
In the rest of the world, PayPal charges a <strong>2%</strong> (up to $20) fee per
transaction.
</li>
</ul>
<p>
Modrinth will deduct <strong>2%</strong> for the fee (minimum of $0.25
and maximum of $20) from <strong>all transfers</strong> and if the fee
PayPal charges is less than the amount we deducted, the difference will
be added back to your Modrinth balance. This happens as Modrinth cannot
determine if a transaction will be in the United States or international
or not until after the transaction has been made.
Modrinth will deduct <strong>2%</strong> for the fee (minimum of $0.25 and maximum of $20)
from <strong>all transfers</strong> and if the fee PayPal charges is less than the amount we
deducted, the difference will be added back to your Modrinth balance. This happens as
Modrinth cannot determine if a transaction will be in the United States or international or
not until after the transaction has been made.
</p>
<h3>Venmo (United States only)</h3>
<p>
Venmo will charge a $0.25 processing fee per transaction, which will be
deducted from the amount you choose to transfer.
Venmo will charge a $0.25 processing fee per transaction, which will be deducted from the
amount you choose to transfer.
</p>
<h2>Currency conversions</h2>
<p>
All revenue generated by Modrinth is in United States dollars. Any
conversions to your local currency will happen at withdrawal and is not
handled by Modrinth. Modrinth cannot guarantee any exchange rate, so
only USD is displayed in the creator dashboard.
All revenue generated by Modrinth is in United States dollars. Any conversions to your local
currency will happen at withdrawal and is not handled by Modrinth. Modrinth cannot guarantee
any exchange rate, so only USD is displayed in the creator dashboard.
</p>
</section>
</div>
</template>
<script>
import TransferIcon from '~/assets/images/utils/transfer.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import TransferIcon from '~/assets/images/utils/transfer.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg'
import ModalTransfer from '~/components/ui/ModalTransfer'
export default {
export default defineNuxtComponent({
components: { TransferIcon, SettingsIcon, ModalTransfer },
async setup() {
const auth = await useAuth()
return { auth }
},
data() {
return {
minWithdraw: 0.26,
enrolled:
this.$auth.user.payout_data.payout_wallet &&
this.$auth.user.payout_data.payout_wallet_type &&
this.$auth.user.payout_data.payout_address,
this.auth.user.payout_data.payout_wallet &&
this.auth.user.payout_data.payout_wallet_type &&
this.auth.user.payout_data.payout_address,
}
},
head: {
title: 'Revenue - Modrinth',
},
methods: {},
}
})
</script>
<style lang="scss" scoped>
strong {

View File

@@ -9,12 +9,6 @@
</div>
</template>
<script>
export default {
auth: false,
}
</script>
<style lang="scss" scoped>
.card {
width: calc(100% - 2 * var(--spacing-card-md));

File diff suppressed because one or more lines are too long

View File

@@ -20,7 +20,7 @@
</aside>
</div>
<div class="normal-page__content">
<NuxtChild class="universal-card" />
<NuxtPage class="universal-card" />
</div>
</div>
</template>
@@ -29,13 +29,12 @@
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import TermsIcon from '~/assets/images/utils/heart-handshake.svg?inline'
import PrivacyIcon from '~/assets/images/utils/lock.svg?inline'
import RulesIcon from '~/assets/images/sidebar/admin.svg?inline'
import ShieldIcon from '~/assets/images/utils/shield.svg?inline'
import TermsIcon from '~/assets/images/utils/heart-handshake.svg'
import PrivacyIcon from '~/assets/images/utils/lock.svg'
import RulesIcon from '~/assets/images/sidebar/admin.svg'
import ShieldIcon from '~/assets/images/utils/shield.svg'
export default {
name: 'Settings',
export default defineNuxtComponent({
components: {
NavStack,
NavStackItem,
@@ -44,11 +43,11 @@ export default {
RulesIcon,
ShieldIcon,
},
}
})
</script>
<style lang="scss" scoped>
.normal-page__content ::v-deep a {
.normal-page__content :deep(a) {
color: var(--color-link);
text-decoration: underline;

View File

@@ -7,31 +7,27 @@
<h2>Foreword</h2>
<p>
The following document was created as required by several laws, including
but not limited to:
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
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
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>
<a href="https://modrinth.com">Modrinth</a> is part of Rinth, Inc. ("us",
"we", "our"). This privacy policy explains how we collect data, process
it, and your rights relative to your data.
<a href="https://modrinth.com">Modrinth</a> is part of Rinth, Inc. ("us", "we", "our"). This
privacy policy explains how we collect data, process it, and your rights relative to your
data.
</p>
<p>
Rinth, Inc. is the data controller for data collected through Modrinth.
</p>
<p>Rinth, Inc. is the data controller for data collected through Modrinth.</p>
<h2>What data do we collect?</h2>
@@ -45,14 +41,12 @@
<li>Your GitHub ID</li>
</ul>
<p>
This data is used to identify you and display your profile. It will be
linked to your projects.
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>
<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>
@@ -60,10 +54,7 @@
<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>
<p>This data is used to monitor automated access to our service and deliver statistics.</p>
<h3>Creator Monetization Program data</h3>
<p>
@@ -77,44 +68,37 @@
<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>
<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.
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:
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>
<a href="https://www.cloudflare.com/en-gb/gdpr/introduction/"> Cloudflare </a>
</li>
<li><a href="https://sentry.io/trust/privacy/">Sentry</a></li>
</ul>
<p>
Data that we specifically collect isn't shared with any other third party.
We do not sell any data.
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.
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>
@@ -124,107 +108,94 @@
</p>
<h2>Cookies</h2>
<p>We use cookies to log you into your account and save your cosmetic preferences.</p>
<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
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.
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>
<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.
<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.
<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.
<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
<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 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.
<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.
<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.
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.
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.
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.
By using our website, you hereby consent to our Privacy Policy and agree to its Terms and
Conditions.
</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.
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
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>
@@ -236,8 +207,8 @@
<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
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>
@@ -251,15 +222,14 @@
United Kingdom
</p>
<p>
You do not need to be a citizen of the United Kingdom to use this method
of lodging complaints.
You do not need to be a citizen of the United Kingdom to use this method of lodging
complaints.
</p>
</div>
</template>
<script>
export default {
auth: false,
export default defineNuxtComponent({
head: {
title: 'Privacy - Modrinth',
meta: [
@@ -282,11 +252,11 @@ export default {
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/legal/privacy`,
content: 'https://modrinth.com/legal/privacy',
},
],
},
}
})
</script>
<style lang="scss" scoped></style>

View File

@@ -4,165 +4,138 @@
<p>
In order to facilitate Modrinth's
<nuxt-link to="/legal/terms">Terms and Conditions</nuxt-link>, all Content
must obey the following Rules. For more information on what exactly
Content is, please refer to the Content section of the Terms.
<nuxt-link to="/legal/terms"> Terms and Conditions </nuxt-link>, all Content must obey the
following Rules. For more information on what exactly Content is, please refer to the Content
section of the Terms.
</p>
<p>
Please note that these are general rules and will not be enforced "to the
letter". We reserve the right to modify and/or remove any file, project,
or other Content uploaded to our platform for any reason. We reserve the
right to introduce new rules at any time, which may or may not
retroactively apply to already uploaded Content at the discretion of our
moderators.
Please note that these are general rules and will not be enforced "to the letter". We reserve
the right to modify and/or remove any file, project, or other Content uploaded to our platform
for any reason. We reserve the right to introduce new rules at any time, which may or may not
retroactively apply to already uploaded Content at the discretion of our moderators.
</p>
<p>
If you find any violations of these Rules on our website, it is your
responsibility to report it. You may use the Report button on any project,
version, or user page, or you may email us at
If you find any violations of these Rules on our website, it is your responsibility to report
it. 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="malicious-content">1. Malicious Content</h2>
<p>
Content cannot contain or download malware, which we define as anything
that is designed:
</p>
<p>Content cannot contain or download malware, which we define as anything that is designed:</p>
<ul>
<li>
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
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>
to disrupt, damage, or otherwise cause harm or damage to an individual,
computer, or network
to disrupt, damage, or otherwise cause harm or damage to an individual, computer, or network
</li>
</ul>
<h2 id="clear-and-honest-function">2. Clear and honest function</h2>
<p>
Content, especially projects, must make a clear and honest attempt to
describe their purpose on the page(s) where it may be found.
Content, especially projects, must make a clear and honest attempt to describe their purpose
on the page(s) where it may be found.
</p>
<p>
Content must not make or share intentionally wrong or misleading claims.
This includes but is not limited to claims regarding the Content itself,
claims regarding other Content, and claims not relating to Content on
Modrinth.
Content must not make or share intentionally wrong or misleading claims. This includes but is
not limited to claims regarding the Content itself, claims regarding other Content, and claims
not relating to Content on Modrinth.
</p>
<h3 id="general-expectations">2.1. General expectations</h3>
<p>
Projects in particular must attempt to describe the following three things
within their description:
Projects in particular must attempt to describe the following three things within their
description:
</p>
<ul>
<li>what a 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>
<li>any other critical information the user must know before downloading</li>
</ul>
<p>
Project descriptions must also be accessible. For the most part, this
means that descriptions cannot mostly consist of text within images, and
necessary information cannot be obscured.
Project descriptions must also be accessible. For the most part, this means that descriptions
cannot mostly consist of text within images, and necessary information cannot be obscured.
</p>
<p>
Projects which don't meet of these expectations may be removed from search
rather than removed from the platform altogether, at the moderators'
discretion.
Projects which don't meet of these expectations may be removed from search rather than removed
from the platform altogether, at the moderators' discretion.
</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:
Projects cannot contain or download "cheats", which we define as a client-side modification
that:
</p>
<ul>
<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
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:
contains any of the following functions without requiring a server-side opt-in:
<ul>
<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 PvP</li>
<li>
active client-side hiding of third party modifications that have
server-side opt-outs
active client-side hiding of third party modifications that have server-side opt-outs
</li>
<li>item duplication</li>
</ul>
</li>
</ul>
<h2 id="copyright-and-legality-of-content">
4. Copyright and legality of Content
</h2>
<h2 id="copyright-and-legality-of-content">4. Copyright and legality of Content</h2>
<p>
You must own or have the necessary licenses, rights, consents, and
permissions to store, share, or distribute the Content that is uploaded
under your Modrinth account.
You must own or have the necessary licenses, rights, consents, and permissions to store,
share, or distribute the Content that is uploaded under your Modrinth account.
</p>
<p>
Content may not be directly "reuploaded" from another platform without the
permission of the author or copyright holder, even with the appropriate
licensing or other rights. This restriction does not apply to content
within modpacks or to so called "forks" - that is, modified copies of a
project which have diverged substantially enough from the original
Content may not be directly "reuploaded" from another platform without the permission of the
author or copyright holder, even with the appropriate licensing or other rights. This
restriction does not apply to content within modpacks or to so called "forks" - that is,
modified copies of a project which have diverged substantially enough from the original
project, at the discretion of Modrinth's moderators.
</p>
<p>
Content must not infringe upon anyone's rights or intellectual property.
</p>
<p>Content must not infringe upon anyone's rights or intellectual property.</p>
<p>
Content must abide by the laws which govern Rinth, Inc., i.e. those of the
United States and of the State of Delaware.
Content must abide by the laws which govern Rinth, Inc., i.e. those of the United States and
of the State of Delaware.
</p>
<h2 id="prohibited-content">5. Prohibited Content</h2>
<p>
Content on Modrinth is meant to be appropriate for audiences 13 years of
age and above.
</p>
<p>Content on Modrinth is meant to be appropriate for audiences 13 years of age and above.</p>
<p>This means that the following Content is not allowed:</p>
<ul>
<li>Content containing sexual or explicit material</li>
<li>Content promoting or sharing harmful or hateful behavior</li>
<li>
Content themed around or containing real-life drugs or illicit
substances
</li>
<li>Content themed around or containing real-life drugs or illicit substances</li>
<li>Content with an excessive amount of profane language</li>
</ul>
</div>
</template>
<script>
export default {
auth: false,
export default defineNuxtComponent({
head: {
title: 'Rules - Modrinth',
meta: [
@@ -185,11 +158,11 @@ export default {
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/legal/rules`,
content: 'https://modrinth.com/legal/rules',
},
],
},
}
})
</script>
<style lang="scss" scoped></style>

View File

@@ -3,13 +3,13 @@
<h1>Security Notice</h1>
<p>
This is the security notice for all Modrinth repositories. The notice
explains how vulnerabilities should be reported.
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.
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>
@@ -17,15 +17,11 @@
including:
</p>
<ul>
<li>
the website, page or repository where the vulnerability can be observed
</li>
<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"
>
<a href="https://www.owasp.org/index.php/Category:OWASP_Top_Ten_2017_Project">
OWASP category
</a>
</li>
@@ -36,17 +32,15 @@
<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
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
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>
<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
@@ -59,8 +53,7 @@
</template>
<script>
export default {
auth: false,
export default defineNuxtComponent({
head: {
title: 'Security Notice - Modrinth',
meta: [
@@ -83,11 +76,11 @@ export default {
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/legal/security`,
content: 'https://modrinth.com/legal/security',
},
],
},
}
})
</script>
<style lang="scss" scoped></style>

View File

@@ -5,151 +5,132 @@
<h2>1. Terms</h2>
<p>
By accessing this Website, accessible from https://modrinth.com, you are
agreeing to be bound by these Website Terms and Conditions of Use and
agree that you are responsible for the agreement with any applicable local
laws. If you disagree with any of these terms, you are prohibited from
accessing this site. The materials contained in this Website are protected
by copyright and trade mark law.
By accessing this Website, accessible from https://modrinth.com, you are agreeing to be bound
by these Website Terms and Conditions of Use and agree that you are responsible for the
agreement with any applicable local laws. If you disagree with any of these terms, you are
prohibited from accessing this site. The materials contained in this Website are protected by
copyright and trade mark law.
</p>
<h2>2. Use License</h2>
<p>
Permission is granted to temporarily download one copy of the materials on
Rinth, Inc.'s Website for personal, non-commercial transitory viewing
only. This is the grant of a license, not a transfer of title, and under
this license you may not:
Permission is granted to temporarily download one copy of the materials on Rinth, Inc.'s
Website for personal, non-commercial transitory viewing only. This is the grant of a license,
not a transfer of title, and under this license you may not:
</p>
<ul>
<li>modify or copy the materials;</li>
<li>use the materials for any commercial purpose or for any public display;</li>
<li>attempt to reverse engineer any software contained on Rinth, Inc.'s Website;</li>
<li>remove any copyright or other proprietary notations from the materials; or</li>
<li>
use the materials for any commercial purpose or for any public display;
</li>
<li>
attempt to reverse engineer any software contained on Rinth, Inc.'s
Website;
</li>
<li>
remove any copyright or other proprietary notations from the materials;
or
</li>
<li>
transferring the materials to another person or "mirror" the materials
on any other server.
transferring the materials to another person or "mirror" the materials on any other server.
</li>
</ul>
<p>
This will let Rinth, Inc. to terminate upon violations of any of these
restrictions. Upon termination, your viewing right will also be terminated
and you should destroy any downloaded materials in your possession whether
it is printed or electronic format.
This will let Rinth, Inc. to terminate upon violations of any of these restrictions. Upon
termination, your viewing right will also be terminated and you should destroy any downloaded
materials in your possession whether it is printed or electronic format.
</p>
<h2>3. Disclaimer</h2>
<p>
All the materials on Rinth, Inc.s Website are provided "as is". Rinth,
Inc. makes no warranties, may it be expressed or implied, therefore
negates all other warranties. Furthermore, Rinth, Inc. does not make any
representations concerning the accuracy or reliability of the use of the
materials on its Website or otherwise relating to such materials or any
sites linked to this Website.
All the materials on Rinth, Inc.s Website are provided "as is". Rinth, Inc. makes no
warranties, may it be expressed or implied, therefore negates all other warranties.
Furthermore, Rinth, Inc. does not make any representations concerning the accuracy or
reliability of the use of the materials on its Website or otherwise relating to such materials
or any sites linked to this Website.
</p>
<h2>4. Limitations</h2>
<p>
Rinth, Inc. or its suppliers will not be hold accountable for any damages
that will arise with the use or inability to use the materials on Rinth,
Inc.s Website, even if Rinth, Inc. or an authorize representative of this
Website has been notified, orally or written, of the possibility of such
damage. Some jurisdiction does not allow limitations on implied warranties
or limitations of liability for incidental damages, these limitations may
not apply to you.
Rinth, Inc. or its suppliers will not be hold accountable for any damages that will arise with
the use or inability to use the materials on Rinth, Inc.s Website, even if Rinth, Inc. or an
authorize representative of this Website has been notified, orally or written, of the
possibility of such damage. Some jurisdiction does not allow limitations on implied warranties
or limitations of liability for incidental damages, these limitations may not apply to you.
</p>
<h2>5. Revisions and Errata</h2>
<p>
The materials appearing on Rinth, Inc.s Website may include technical,
typographical, or photographic errors. Rinth, Inc. will not promise that
any of the materials in this Website are accurate, complete, or current.
Rinth, Inc. may change the materials contained on its Website at any time
without notice. Rinth, Inc. does not make any commitment to update the
The materials appearing on Rinth, Inc.s Website may include technical, typographical, or
photographic errors. Rinth, Inc. will not promise that any of the materials in this Website
are accurate, complete, or current. Rinth, Inc. may change the materials contained on its
Website at any time without notice. Rinth, Inc. does not make any commitment to update the
materials.
</p>
<h2>6. Links</h2>
<p>
Rinth, Inc. has not reviewed all of the sites linked to its Website and is
not responsible for the contents of any such linked site. The presence of
any link does not imply endorsement by Rinth, Inc. of the site. The use of
any linked website is at the users own risk.
Rinth, Inc. has not reviewed all of the sites linked to its Website and is not responsible for
the contents of any such linked site. The presence of any link does not imply endorsement by
Rinth, Inc. of the site. The use of any linked website is at the users own risk.
</p>
<h2>7. Site Terms of Use Modifications</h2>
<p>
Rinth, Inc. may revise these Terms of Use for its Website at any time
without prior notice. By using this Website, you are agreeing to be bound
by the current version of these Terms and Conditions of Use.
Rinth, Inc. may revise these Terms of Use for its Website at any time without prior notice. By
using this Website, you are agreeing to be bound by the current version of these Terms and
Conditions of Use.
</p>
<h2>8. Your Privacy</h2>
<p>
Please read our
<nuxt-link to="/legal/privacy"> Privacy Policy</nuxt-link>.
<nuxt-link to="/legal/privacy"> Privacy Policy </nuxt-link>.
</p>
<h2>9. Governing Law</h2>
<p>
Any claim related to Rinth, Inc.'s Website shall be governed by the laws
of us without regards to its conflict of law provisions.
Any claim related to Rinth, Inc.'s Website shall be governed by the laws of us without regards
to its conflict of law provisions.
</p>
<h2>10. Content</h2>
<p>
When you upload text, software, mods, scripts, graphics, photos, audio,
videos, links, interactive features and other materials that may be viewed
on or accessed through Modrinth, we refer to it as "Content".
When you upload text, software, mods, scripts, graphics, photos, audio, videos, links,
interactive features and other materials that may be viewed on or accessed through Modrinth,
we refer to it as "Content".
</p>
<ul>
<li>
You are responsible for all activity and Content that is uploaded under
your Modrinth account.
You are responsible for all activity and Content that is uploaded under your Modrinth
account.
</li>
<li>
You retain all of your ownership rights to your Content. We do not claim
any ownership in or to any of your Content.
You retain all of your ownership rights to your Content. We do not claim any ownership in or
to any of your Content.
</li>
<li>
To enable us to provide the services of Modrinth, you hereby grant us a
worldwide, non-exclusive, royalty-free, and unrestricted license to use,
reproduce, distribute copies, prepare derivative works of, or display
Content in connection with Modrinth in any medium and for any purpose
(including commercial purposes).
To enable us to provide the services of Modrinth, you hereby grant us a worldwide,
non-exclusive, royalty-free, and unrestricted license to use, reproduce, distribute copies,
prepare derivative works of, or display Content in connection with Modrinth in any medium
and for any purpose (including commercial purposes).
</li>
</ul>
<p>
All Content on Modrinth must obey the
<nuxt-link to="/legal/rules">Content Rules</nuxt-link>. Please be aware of
these Rules before uploading any Content to Modrinth.
<nuxt-link to="/legal/rules"> Content Rules </nuxt-link>. Please be aware of these Rules
before uploading any Content to Modrinth.
</p>
</div>
</template>
<script>
export default {
auth: false,
export default defineNuxtComponent({
head: {
title: 'Terms - Modrinth',
meta: [
@@ -172,11 +153,11 @@ export default {
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/legal/terms`,
content: 'https://modrinth.com/legal/terms',
},
],
},
}
})
</script>
<style lang="scss" scoped></style>

View File

@@ -11,22 +11,21 @@
<aside class="universal-card">
<h1>Moderation</h1>
<NavStack>
<NavStackItem link="" label="All"> </NavStackItem>
<NavStackItem link="/moderation" label="All" />
<NavStackItem
v-for="type in moderationTypes"
:key="type"
:link="'?type=' + type"
:link="'/moderation/' + type"
:label="$formatProjectType(type) + 's'"
>
</NavStackItem>
/>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<div class="project-list display-mode--list">
<ProjectCard
v-for="project in $route.query.type !== undefined
? projects.filter((x) => x.project_type === $route.query.type)
v-for="project in $route.params.type !== undefined
? projects.filter((x) => x.project_type === $route.params.type)
: projects"
:id="project.slug || project.id"
:key="project.id"
@@ -47,56 +46,40 @@
@click="
setProjectStatus(
project,
project.requested_status
? project.requested_status
: 'approved'
project.requested_status ? project.requested_status : 'approved'
)
"
>
<CheckIcon />
Approve
</button>
<button
class="iconified-button"
@click="setProjectStatus(project, 'withheld')"
>
<button class="iconified-button" @click="setProjectStatus(project, 'withheld')">
<UnlistIcon />
Withhold
</button>
<button
class="iconified-button"
@click="setProjectStatus(project, 'rejected')"
>
<button class="iconified-button" @click="setProjectStatus(project, 'rejected')">
<CrossIcon />
Reject
</button>
</ProjectCard>
</div>
<div
v-if="
$route.query.type === 'report' || $route.query.type === undefined
"
v-if="$route.params.type === 'report' || $route.params.type === undefined"
class="reports"
>
<div
v-for="(item, index) in reports"
:key="index"
class="card report"
>
<div v-for="(item, index) in reports" :key="index" class="card report">
<div class="info">
<div class="title">
<h3>
{{ item.item_type }}
<nuxt-link :to="item.url">{{ item.item_id }}</nuxt-link>
<nuxt-link :to="item.url">
{{ item.item_id }}
</nuxt-link>
</h3>
reported by
<a :href="`/user/${item.reporter}`">{{ item.reporter }}</a>
</div>
<div
v-highlightjs
class="markdown-body"
v-html="$xss($md.render(item.body))"
/>
<div class="markdown-body" v-html="renderHighlightedString(item.body)" />
<Badge :type="`Marked as ${item.report_type}`" color="orange" />
</div>
<div class="actions">
@@ -104,21 +87,17 @@
<TrashIcon /> Delete report
</button>
<span
v-tooltip="
$dayjs(item.created).format(
'[Created at] YYYY-MM-DD [at] HH:mm A'
)
"
v-tooltip="$dayjs(item.created).format('[Created at] YYYY-MM-DD [at] HH:mm A')"
class="stat"
>
<CalendarIcon />
Created {{ $dayjs(item.created).fromNow() }}
Created {{ fromNow(item.created) }}
</span>
</div>
</div>
</div>
<div v-if="reports.length === 0 && projects.length === 0" class="error">
<Security class="icon"></Security>
<Security class="icon" />
<br />
<span class="text">You are up-to-date!</span>
</div>
@@ -131,18 +110,18 @@
import ProjectCard from '~/components/ui/ProjectCard'
import Badge from '~/components/ui/Badge'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import UnlistIcon from '~/assets/images/utils/eye-off.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import Security from '~/assets/images/illustrations/security.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg'
import UnlistIcon from '~/assets/images/utils/eye-off.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import Security from '~/assets/images/illustrations/security.svg'
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import ModalModeration from '~/components/ui/ModalModeration'
import { renderHighlightedString } from '~/helpers/highlight'
export default {
name: 'Moderation',
export default defineNuxtComponent({
components: {
ModalModeration,
NavStack,
@@ -156,13 +135,17 @@ export default {
TrashIcon,
CalendarIcon,
},
async asyncData(data) {
const [projects, reports] = (
await Promise.all([
data.$axios.get(`moderation/projects`, data.$defaultHeaders()),
data.$axios.get(`report`, data.$defaultHeaders()),
])
).map((it) => it.data)
async setup() {
const data = useNuxtApp()
definePageMeta({
middleware: 'auth',
})
const [projects, reports] = await Promise.all([
useBaseFetch('moderation/projects', data.$defaultHeaders()),
useBaseFetch('report', data.$defaultHeaders()),
])
const newReports = await Promise.all(
reports.map(async (report) => {
@@ -173,48 +156,26 @@ export default {
let url = ''
if (report.item_type === 'user') {
const user = (
await data.$axios.get(
`user/${report.item_id}`,
data.$defaultHeaders()
)
).data
const user = await useBaseFetch(`user/${report.item_id}`, data.$defaultHeaders())
url = `/user/${user.username}`
report.item_id = user.username
} else if (report.item_type === 'project') {
const project = (
await data.$axios.get(
`project/${report.item_id}`,
data.$defaultHeaders()
)
).data
const project = await useBaseFetch(`project/${report.item_id}`, data.$defaultHeaders())
report.item_id = project.slug || report.item_id
url = `/${project.project_type}/${report.item_id}`
} else if (report.item_type === 'version') {
const version = (
await data.$axios.get(
`version/${report.item_id}`,
data.$defaultHeaders()
)
).data
const project = (
await data.$axios.get(
`project/${version.project_id}`,
data.$defaultHeaders()
)
).data
const version = await useBaseFetch(`version/${report.item_id}`, data.$defaultHeaders())
const project = await useBaseFetch(
`project/${version.project_id}`,
data.$defaultHeaders()
)
report.item_id = version.version_number || report.item_id
url = `/${project.project_type}/${
project.slug || project.id
}/version/${report.item_id}`
url = `/${project.project_type}/${project.slug || project.id}/version/${report.item_id}`
}
report.reporter = (
await data.$axios.get(
`user/${report.reporter}`,
data.$defaultHeaders()
)
).data.username
await useBaseFetch(`user/${report.reporter}`, data.$defaultHeaders())
).username
return {
...report,
@@ -232,8 +193,8 @@ export default {
)
return {
projects,
reports: newReports,
projects: shallowRef(projects),
reports: ref(newReports),
}
},
data() {
@@ -261,6 +222,7 @@ export default {
},
},
methods: {
renderHighlightedString,
setProjectStatus(project, status) {
this.currentProject = project
this.currentStatus = status
@@ -275,28 +237,28 @@ export default {
this.currentProject = null
},
async deleteReport(index) {
this.$nuxt.$loading.start()
startLoading()
try {
await this.$axios.delete(
`report/${this.reports[index].id}`,
this.$defaultHeaders()
)
await useBaseFetch(`report/${this.reports[index].id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
this.reports.splice(index, 1)
} catch (err) {
console.error(err)
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
},
}
})
</script>
<style lang="scss" scoped>

View File

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

View File

@@ -4,24 +4,20 @@
<aside class="universal-card">
<h1>Notifications</h1>
<NavStack>
<NavStackItem link="" label="All"> </NavStackItem>
<NavStackItem link="/notifications" label="All" :uses-query="true" />
<NavStackItem
v-for="type in notificationTypes"
:key="type"
:link="'?type=' + type"
:link="'/notifications/' + type"
:label="NOTIFICATION_TYPES[type]"
>
</NavStackItem>
:uses-query="true"
/>
<h3>Manage</h3>
<NavStackItem
link="/settings/follows"
label="Followed projects"
chevron
>
<NavStackItem link="/settings/follows" label="Followed projects" chevron>
<SettingsIcon />
</NavStackItem>
<NavStackItem
v-if="$user.notifications.length > 0"
v-if="user.notifications.length > 0"
:action="clearNotifications"
label="Clear all"
danger
@@ -34,30 +30,26 @@
<div class="normal-page__content">
<div class="notifications">
<div
v-for="notification in $route.query.type !== undefined
? $user.notifications.filter((x) => x.type === $route.query.type)
: $user.notifications"
v-for="notification in $route.params.type !== undefined
? user.notifications.filter((x) => x.type === $route.params.type)
: user.notifications"
:key="notification.id"
class="universal-card adjacent-input"
>
<div class="label">
<span class="label__title">
<nuxt-link :to="notification.link">
<h3 v-html="$xss($md.render(notification.title))" />
<h3 v-html="renderString(notification.title)" />
</nuxt-link>
</span>
<div class="label__description">
<p>{{ notification.text }}</p>
<span
v-tooltip="
$dayjs(notification.created).format(
'MMMM D, YYYY [at] h:mm:ss A'
)
"
v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm:ss A')"
class="date"
>
<CalendarIcon />
Received {{ $dayjs(notification.created).fromNow() }}</span
Received {{ fromNow(notification.created) }}</span
>
</div>
</div>
@@ -66,12 +58,8 @@
v-for="(action, actionIndex) in notification.actions"
:key="actionIndex"
class="iconified-button"
:class="`action-button-${action.title
.toLowerCase()
.replaceAll(' ', '-')}`"
@click="
performAction(notification, notificationIndex, actionIndex)
"
:class="`action-button-${action.title.toLowerCase().replaceAll(' ', '-')}`"
@click="performAction(notification, notificationIndex, actionIndex)"
>
{{ action.title }}
</button>
@@ -84,8 +72,8 @@
</button>
</div>
</div>
<div v-if="$user.notifications.length === 0" class="error">
<UpToDate class="icon"></UpToDate>
<div v-if="user.notifications.length === 0" class="error">
<UpToDate class="icon" />
<br />
<span class="text">You are up-to-date!</span>
</div>
@@ -95,14 +83,15 @@
</template>
<script>
import ClearIcon from '~/assets/images/utils/clear.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?inline'
import ClearIcon from '~/assets/images/utils/clear.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg'
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
export default {
name: 'Notifications',
import { renderString } from '~/helpers/parse'
export default defineNuxtComponent({
components: {
NavStack,
NavStackItem,
@@ -111,8 +100,17 @@ export default {
CalendarIcon,
UpToDate,
},
async fetch() {
await this.$store.dispatch('user/fetchNotifications')
async setup() {
definePageMeta({
middleware: 'auth',
})
const user = await useUser()
if (process.client) {
await initUserNotifs()
}
return { user: ref(user) }
},
head: {
title: 'Notifications - Modrinth',
@@ -121,9 +119,7 @@ export default {
notificationTypes() {
const obj = {}
for (const notification of this.$user.notifications.filter(
(it) => it.type !== null
)) {
for (const notification of this.user.notifications.filter((it) => it.type !== null)) {
obj[notification.type] = true
}
@@ -138,58 +134,56 @@ export default {
}
},
methods: {
renderString,
async clearNotifications() {
try {
const ids = this.$user.notifications.map((x) => x.id)
const ids = this.user.notifications.map((x) => x.id)
await this.$axios.delete(
`notifications?ids=${JSON.stringify(ids)}`,
this.$defaultHeaders()
)
ids.forEach((x) => this.$store.dispatch('user/deleteNotification', x))
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
await useBaseFetch(`notifications?ids=${JSON.stringify(ids)}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
}
},
async performAction(notification, notificationIndex, actionIndex) {
this.$nuxt.$loading.start()
try {
await this.$axios.delete(
`notification/${notification.id}`,
this.$defaultHeaders()
)
await this.$store.dispatch('user/deleteNotification', notification.id)
if (actionIndex !== null) {
const config = {
method:
notification.actions[actionIndex].action_route[0].toLowerCase(),
url: `${notification.actions[actionIndex].action_route[1]}`,
headers: {
Authorization: this.$auth.token,
},
}
await this.$axios(config)
for (const id of ids) {
await userDeleteNotification(id)
}
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
async performAction(notification, _notificationIndex, actionIndex) {
startLoading()
try {
await useBaseFetch(`notification/${notification.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await userDeleteNotification(notification.id)
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
...this.$defaultHeaders(),
})
}
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
},
},
}
})
</script>
<style lang="scss" scoped>
@@ -201,7 +195,7 @@ export default {
align-items: baseline;
margin-block-start: 0;
h3 ::v-deep {
:deep(h3) {
margin: 0;
p {
margin: 0;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'Datapacks',
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,11 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'Modpacks',
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,11 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'Mods',
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,11 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'Plugins',
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,11 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'ResourcePacks',
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,11 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: 'Shaders',
}
</script>
<style lang="scss" scoped></style>

View File

@@ -23,31 +23,18 @@
</aside>
</div>
<div class="normal-page__content">
<NuxtChild />
<NuxtPage />
</div>
</div>
</template>
<script>
<script setup>
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import PaintbrushIcon from '~/assets/images/utils/paintbrush.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import CurrencyIcon from '~/assets/images/utils/currency.svg?inline'
export default {
name: 'Settings',
components: {
NavStack,
NavStackItem,
PaintbrushIcon,
UserIcon,
HeartIcon,
CurrencyIcon,
},
}
import PaintbrushIcon from '~/assets/images/utils/paintbrush.svg'
import UserIcon from '~/assets/images/utils/user.svg'
import HeartIcon from '~/assets/images/utils/heart.svg'
import CurrencyIcon from '~/assets/images/utils/currency.svg'
</script>
<style lang="scss" scoped></style>

View File

@@ -5,7 +5,7 @@
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.gg/EUHuJHt)."
proceed-label="Delete this account"
:confirmation-text="$auth.user.username"
:confirmation-text="auth.user.username"
:has-to-type="true"
@proceed="deleteAccount"
/>
@@ -13,18 +13,14 @@
<Modal ref="modal_revoke_token" header="Revoke your Modrinth token">
<div class="modal-revoke-token markdown-body">
<p>
Revoking your Modrinth token can have unintended consequences. Please
be aware that the following could break:
Revoking your Modrinth token can have unintended consequences. Please be aware that the
following could break:
</p>
<ul>
<li>Any application that uses your token to access the API.</li>
<li>Gradle - if Minotaur is given a incorrect token, your Gradle builds could fail.</li>
<li>
Gradle - if Minotaur is given a incorrect token, your Gradle builds
could fail.
</li>
<li>
GitHub - if you use a GitHub action that uses the Modrinth API, it
will cause errors.
GitHub - if you use a GitHub action that uses the Modrinth API, it will cause errors.
</li>
</ul>
<p>If you are willing to continue, complete the following steps:</p>
@@ -33,32 +29,23 @@
<a
href="https://github.com/settings/connections/applications/3acffb2e808d16d4b226"
target="_blank"
rel="noopener noreferrer nofollow"
rel="noopener"
>
Head to the Modrinth Application page on GitHub.
</a>
Make sure to be logged into the GitHub account you used for
Modrinth!
</li>
<li>
Press the big red "Revoke Access" button next to the "Permissions"
header.
Make sure to be logged into the GitHub account you used for Modrinth!
</li>
<li>Press the big red "Revoke Access" button next to the "Permissions" header.</li>
</ol>
<p>
Once you have completed those steps, press the continue button below.
</p>
<p>Once you have completed those steps, press the continue button below.</p>
<p>
<strong>
This will log you out of Modrinth, however, when you log back in,
your token will be regenerated.
This will log you out of Modrinth, however, when you log back in, your token will be
regenerated.
</strong>
</p>
<div class="button-group">
<button
class="iconified-button"
@click="$refs.modal_revoke_token.hide()"
>
<button class="iconified-button" @click="$refs.modal_revoke_token.hide()">
<CrossIcon />
Cancel
</button>
@@ -73,7 +60,7 @@
<section class="universal-card">
<h2>User profile</h2>
<p>Visit your user profile to edit your profile information.</p>
<NuxtLink class="iconified-button" :to="`/user/${$auth.user.username}`">
<NuxtLink class="iconified-button" :to="`/user/${auth.user.username}`">
<UserIcon /> Visit your profile
</NuxtLink>
</section>
@@ -83,13 +70,11 @@
<p>Your account information is not displayed publicly.</p>
<ul class="known-errors">
<li v-if="hasMonetizationEnabled() && !email">
You must have an email address set since you are enrolled in the
Creator Monetization Program.
You must have an email address set since you are enrolled in the Creator Monetization
Program.
</li>
</ul>
<label for="email-input"
><span class="label__title">Email address</span>
</label>
<label for="email-input"><span class="label__title">Email address</span> </label>
<input
id="email-input"
v-model="email"
@@ -113,28 +98,18 @@
<section class="universal-card">
<h2>Authorization token</h2>
<p>
Your authorization token can be used with the Modrinth API, the Minotaur
Gradle plugin, and other applications that interact with Modrinth's API.
Be sure to keep this secret!
Your authorization token can be used with the Modrinth API, the Minotaur Gradle plugin, and
other applications that interact with Modrinth's API. Be sure to keep this secret!
</p>
<div class="input-group">
<button
type="button"
class="iconified-button"
value="Copy to clipboard"
@click="copyToken"
>
<button type="button" class="iconified-button" value="Copy to clipboard" @click="copyToken">
<template v-if="copied">
<CheckIcon />
Copied token to clipboard
</template>
<template v-else><CopyIcon />Copy token to clipboard</template>
<template v-else> <CopyIcon />Copy token to clipboard </template>
</button>
<button
type="button"
class="iconified-button"
@click="$refs.modal_revoke_token.show()"
>
<button type="button" class="iconified-button" @click="$refs.modal_revoke_token.show()">
<SlashIcon />
Revoke token
</button>
@@ -144,9 +119,8 @@
<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.
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"
@@ -164,16 +138,16 @@
import ModalConfirm from '~/components/ui/ModalConfirm'
import Modal from '~/components/ui/Modal'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import CopyIcon from '~/assets/images/utils/clipboard-copy.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import SlashIcon from '~/assets/images/utils/slash.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import UserIcon from '~/assets/images/utils/user.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import CopyIcon from '~/assets/images/utils/clipboard-copy.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import SlashIcon from '~/assets/images/utils/slash.svg'
export default {
export default defineNuxtComponent({
components: {
Modal,
ModalConfirm,
@@ -186,10 +160,19 @@ export default {
TrashIcon,
SlashIcon,
},
async setup() {
definePageMeta({
middleware: 'auth',
})
const auth = await useAuth()
return { auth }
},
data() {
return {
copied: false,
email: this.$auth.user.email,
email: this.auth.user.email,
showKnownErrors: false,
}
},
@@ -199,43 +182,41 @@ export default {
methods: {
async copyToken() {
this.copied = true
await navigator.clipboard.writeText(this.$auth.token)
await navigator.clipboard.writeText(this.auth.token)
},
async deleteAccount() {
this.$nuxt.$loading.start()
startLoading()
try {
await this.$axios.delete(
`user/${this.$auth.user.id}`,
this.$defaultHeaders()
)
await useBaseFetch(`user/${this.auth.user.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$cookies.set('auth-token-reset', true)
alert(
'Please note that logging back in with GitHub will create a new account.'
)
useCookie('auth-token').value = null
alert('Please note that logging back in with GitHub will create a new account.')
window.location.href = '/'
this.$nuxt.$loading.finish()
stopLoading()
},
logout() {
this.$refs.modal_revoke_token.hide()
this.$cookies.set('auth-token-reset', true)
useCookie('auth-token').value = null
window.location.href = `${this.$axios.defaults.baseURL}auth/init?url=${process.env.domain}`
window.location.href = getAuthUrl()
},
hasMonetizationEnabled() {
return (
this.$auth.user.payout_data.payout_wallet &&
this.$auth.user.payout_data.payout_wallet_type &&
this.$auth.user.payout_data.payout_address
this.auth.user.payout_data.payout_wallet &&
this.auth.user.payout_data.payout_wallet_type &&
this.auth.user.payout_data.payout_address
)
},
async saveChanges() {
@@ -243,32 +224,30 @@ export default {
this.showKnownErrors = true
return
}
this.$nuxt.$loading.start()
startLoading()
try {
const data = {
email: this.email ? this.email : null,
}
await this.$axios.patch(
`user/${this.$auth.user.id}`,
data,
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
await useBaseFetch(`user/${this.auth.user.id}`, {
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
})
await useAuth(this.auth.token)
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
},
}
})
</script>
<style lang="scss" scoped>
.modal-revoke-token {

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="$user.follows.length > 0" class="project-list display-mode--list">
<div v-if="user.follows.length > 0" class="project-list display-mode--list">
<ProjectCard
v-for="project in $user.follows"
v-for="project in user.follows"
:id="project.id"
:key="project.id"
:type="project.project_type"
@@ -16,10 +16,7 @@
:server-side="project.server_side"
:color="project.color"
>
<button
class="iconified-button"
@click="$store.dispatch('user/unfollowProject', project)"
>
<button class="iconified-button" @click="userUnfollowProject(project)">
<HeartIcon />
Unfollow
</button>
@@ -30,30 +27,25 @@
<br />
<span class="text"
>You don't have any followed projects. <br />
Why don't you <nuxt-link class="link" to="/mods">search</nuxt-link> for
new ones?</span
Why don't you <nuxt-link class="link" to="/mods">search</nuxt-link> for new ones?</span
>
</div>
</template>
<script>
<script setup>
import ProjectCard from '~/components/ui/ProjectCard'
import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import FollowIllustration from '~/assets/images/illustrations/follow_illustration.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg'
import FollowIllustration from '~/assets/images/illustrations/follow_illustration.svg'
export default {
components: {
ProjectCard,
HeartIcon,
FollowIllustration,
},
async fetch() {
await this.$store.dispatch('user/fetchFollows')
},
head: {
title: 'Followed projects - Modrinth',
},
const user = await useUser()
if (process.client) {
await initUserFollows()
}
useHead({ title: 'Followed projects - Modrinth' })
definePageMeta({
middleware: 'auth',
})
</script>
<style lang="scss" scoped></style>

View File

@@ -5,57 +5,54 @@
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">Color theme</span>
<span class="label__description"
>Change the global site color theme.</span
>
<span class="label__description">Change the global site color theme.</span>
</label>
<Multiselect
id="theme-selector"
v-model="$colorMode.preference"
:options="['system', 'light', 'dark', 'oled']"
:custom-label="
(value) =>
value === 'oled'
? 'OLED'
: value.charAt(0).toUpperCase() + value.slice(1)
"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<div>
<Multiselect
id="theme-selector"
v-model="$colorMode.preference"
:options="['system', 'light', 'dark', 'oled']"
:custom-label="
(value) =>
value === 'oled' ? 'OLED' : value.charAt(0).toUpperCase() + value.slice(1)
"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="(value) => updateTheme(value, true)"
/>
</div>
</div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">Search sidebar on the right</span>
<span class="label__description"
>Enabling this will put the search page's filters sidebar on the
right side.</span
>Enabling this will put the search page's filters sidebar on the right side.</span
>
</label>
<input
id="search-layout-toggle"
v-model="searchLayout"
v-model="$cosmetics.searchLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmeticSettings"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">Project sidebar on the right</span>
<span class="label__description"
>Enabling this will put the project pages' info sidebars on the
right side.</span
>Enabling this will put the project pages' info sidebars on the right side.</span
>
</label>
<input
id="project-layout-toggle"
v-model="projectLayout"
v-model="$cosmetics.projectLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmeticSettings"
@change="saveCosmetics"
/>
</div>
</section>
@@ -74,14 +71,14 @@
</label>
<Multiselect
:id="projectType + '-search-display-mode'"
:value="searchDisplayMode[projectType.id]"
v-model="$cosmetics.searchDisplayMode[projectType.id]"
:options="$tag.projectViewModes"
:custom-label="$capitalizeString"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@input="(value) => setSearchDisplayMode(projectType.id, value)"
@update:model-value="saveCosmetics"
/>
</div>
</section>
@@ -91,49 +88,46 @@
<label for="advanced-rendering">
<span class="label__title">Advanced rendering</span>
<span class="label__description"
>Enables advanced rendering such as blur effects that may cause
performance issues without hardware-accelerated rendering.</span
>Enables advanced rendering such as blur effects that may cause performance issues
without hardware-accelerated rendering.</span
>
</label>
<input
id="advanced-rendering"
v-model="advancedRendering"
v-model="$cosmetics.advancedRendering"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmeticSettings"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="modpacks-alpha-notice">
<span class="label__title">Modpacks alpha notice</span>
<span class="label__description"
>Shows a banner stating that modpacks are in alpha.</span
>
<span class="label__description">Shows a banner stating that modpacks are in alpha.</span>
</label>
<input
id="modpacks-alpha-notice"
v-model="modpacksAlphaNotice"
v-model="$cosmetics.modpacksAlphaNotice"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmeticSettings"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="external-links-new-tab">
<span class="label__title">Open external links in new tab</span>
<span class="label__description">
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.
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.
</span>
</label>
<input
id="external-links-new-tab"
v-model="externalLinksNewTab"
v-model="$cosmetics.externalLinksNewTab"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmeticSettings"
@change="saveCosmetics"
/>
</div>
</section>
@@ -143,37 +137,15 @@
<script>
import Multiselect from 'vue-multiselect'
export default {
export default defineNuxtComponent({
components: {
Multiselect,
},
auth: false,
data() {
return {
searchLayout: false,
projectLayout: false,
modpacksAlphaNotice: true,
advancedRendering: true,
externalLinksNewTab: true,
searchDisplayMode: {
mod: 'list',
plugin: 'list',
resourcepack: 'gallery',
modpack: 'list',
shader: 'gallery',
datapack: 'list',
user: 'list',
},
searchDisplayMode: this.$cosmetics.searchDisplayMode,
}
},
fetch() {
this.searchLayout = this.$store.state.cosmetics.searchLayout
this.projectLayout = this.$store.state.cosmetics.projectLayout
this.modpacksAlphaNotice = this.$store.state.cosmetics.modpacksAlphaNotice
this.advancedRendering = this.$store.state.cosmetics.advancedRendering
this.externalLinksNewTab = this.$store.state.cosmetics.externalLinksNewTab
this.searchDisplayMode = this.$store.state.cosmetics.searchDisplayMode
},
head: {
title: 'Display settings - Modrinth',
},
@@ -183,10 +155,7 @@ export default {
return {
id: type.id,
name: this.$formatProjectType(type.id) + ' search',
display:
'the ' +
this.$formatProjectType(type.id).toLowerCase() +
's search page',
display: 'the ' + this.$formatProjectType(type.id).toLowerCase() + 's search page',
}
})
types.push({
@@ -197,40 +166,6 @@ export default {
return types
},
},
methods: {
async saveCosmeticSettings() {
await this.$store.dispatch('cosmetics/save', {
searchLayout: this.searchLayout,
projectLayout: this.projectLayout,
modpacksAlphaNotice: this.modpacksAlphaNotice,
advancedRendering: this.advancedRendering,
externalLinksNewTab: this.externalLinksNewTab,
searchDisplayMode: this.searchDisplayMode,
$cookies: this.$cookies,
})
},
async setSearchDisplayMode(projectType, value) {
await this.$store.dispatch('cosmetics/saveSearchDisplayMode', {
projectType,
mode: value,
$cookies: this.$cookies,
})
this.searchDisplayMode = this.$store.state.cosmetics.searchDisplayMode
},
changeTheme() {
const shift = event.shiftKey
switch (this.$colorMode.preference) {
case 'dark':
this.$colorMode.preference = shift ? 'light' : 'oled'
break
case 'oled':
this.$colorMode.preference = shift ? 'dark' : 'light'
break
default:
this.$colorMode.preference = shift ? 'oled' : 'dark'
}
},
},
}
})
</script>
<style lang="scss" scoped></style>

View File

@@ -9,11 +9,10 @@
</section>
<section class="universal-card">
<h2 class="title">Enrollment</h2>
<template v-if="!enrolled && !$auth.user.email">
<template v-if="!enrolled && !auth.user.email">
<p v-if="!enrolled">
You are not currently enrolled in Modrinth's Creator Monetization
Program. In order to enroll, you must first add a valid email address
to your account.
You are not currently enrolled in Modrinth's Creator Monetization Program. In order to
enroll, you must first add a valid email address to your account.
</p>
<NuxtLink class="iconified-button" to="/settings/account">
<SettingsIcon /> Visit account settings
@@ -21,9 +20,8 @@
</template>
<template v-else-if="editing || !enrolled">
<p v-if="!enrolled">
You are not currently enrolled in Modrinth's Creator Monetization
Program. Setup a method of receiving payments below to enable
monetization.
You are not currently enrolled in Modrinth's Creator Monetization Program. Setup a method
of receiving payments below to enable monetization.
</p>
<div class="enroll universal-body">
<Chips
@@ -31,13 +29,13 @@
:starting-value="selectedWallet"
:items="wallets"
:format-label="$formatWallet"
@input="onChangeWallet()"
@update:model-value="onChangeWallet()"
/>
<p>
Enter the information for the
{{ $formatWallet(selectedWallet) }} account you would like to
receive your revenue from the Creator Monetization Program:
{{ $formatWallet(selectedWallet) }} account you would like to receive your revenue from
the Creator Monetization Program:
</p>
<div class="input-group">
<Multiselect
@@ -52,26 +50,20 @@
<label class="hidden" for="account-input"
>{{ $formatWallet(selectedWallet) }}
{{ formatAccountType(accountType).toLowerCase() }} input
field</label
{{ formatAccountType(accountType).toLowerCase() }} input field</label
>
<input
id="account-input"
v-model="account"
:placeholder="`Enter your ${$formatWallet(
selectedWallet
)} ${formatAccountType(accountType).toLowerCase()}...`"
:placeholder="`Enter your ${$formatWallet(selectedWallet)} ${formatAccountType(
accountType
).toLowerCase()}...`"
:type="accountType === 'email' ? 'email' : ''"
/>
<span v-if="accountType === 'phone'">
Format: +18888888888 or +1-888-888-8888
</span>
<span v-if="accountType === 'phone'"> Format: +18888888888 or +1-888-888-8888 </span>
</div>
<div class="input-group">
<button
class="iconified-button brand-button"
@click="updatePayoutData(false)"
>
<button class="iconified-button brand-button" @click="updatePayoutData(false)">
<SaveIcon /> Save information
</button>
<button
@@ -100,13 +92,13 @@
<script>
import Multiselect from 'vue-multiselect'
import Chips from '~/components/ui/Chips'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import ChartIcon from '~/assets/images/utils/chart.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import EditIcon from '~/assets/images/utils/edit.svg'
import ChartIcon from '~/assets/images/utils/chart.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg'
export default {
export default defineNuxtComponent({
components: {
Multiselect,
Chips,
@@ -116,19 +108,24 @@ export default {
ChartIcon,
SettingsIcon,
},
async setup() {
definePageMeta({
middleware: 'auth',
})
const auth = await useAuth()
return { auth }
},
data() {
return {
editing: false,
enrolled:
this.$auth.user.payout_data.payout_wallet &&
this.$auth.user.payout_data.payout_wallet_type &&
this.$auth.user.payout_data.payout_address,
this.auth.user.payout_data.payout_wallet &&
this.auth.user.payout_data.payout_wallet_type &&
this.auth.user.payout_data.payout_address,
wallets: ['paypal', 'venmo'],
selectedWallet: this.$auth.user.payout_data.payout_wallet ?? 'paypal',
accountType:
this.$auth.user.payout_data.payout_wallet_type ??
this.getAccountTypes()[0],
account: this.$auth.user.payout_data.payout_address ?? '',
selectedWallet: this.auth.user.payout_data.payout_wallet ?? 'paypal',
accountType: this.auth.user.payout_data.payout_wallet_type ?? this.getAccountTypes()[0],
account: this.auth.user.payout_data.payout_address ?? '',
}
},
head: {
@@ -167,7 +164,7 @@ export default {
}
},
async updatePayoutData(unenroll) {
this.$nuxt.$loading.start()
startLoading()
if (unenroll) {
this.selectedWallet = 'paypal'
this.accountType = this.getAccountTypes()[0]
@@ -184,14 +181,12 @@ export default {
},
}
await this.$axios.patch(
`user/${this.$auth.user.id}`,
data,
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
await useBaseFetch(`user/${this.auth.user.id}`, {
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
})
await useAuth(this.auth.token)
this.editing = false
this.enrolled = !unenroll
@@ -199,13 +194,13 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
},
}
})
</script>
<style lang="scss" scoped></style>

531
pages/user/[id].vue Normal file
View File

@@ -0,0 +1,531 @@
<template>
<div v-if="user">
<Head>
<Title>{{ user.username + ' - Modrinth' }}</Title>
<Meta name="og:title" :content="user.username" />
<Meta name="description" :content="metaDescription" />
<Meta name="og:type" content="website" />
<Meta name="apple-mobile-web-app-title" :content="metaDescription" />
<Meta name="og:description" :content="metaDescription" />
<Meta
name="og:image"
:content="user.avatar_url ? user.avatar_url : 'https://cdn.modrinth.com/placeholder.png'"
/>
</Head>
<ModalCreation ref="modal_creation" />
<ModalReport ref="modal_report" :item-id="user.id" item-type="user" />
<div class="user-header-wrapper">
<div class="user-header">
<Avatar
:src="previewImage ? previewImage : 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">
<FileInput
v-if="isEditing"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload avatar"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<button
v-else-if="$auth.user && $auth.user.id === user.id"
class="iconified-button"
@click="isEditing = true"
>
<EditIcon />
Edit
</button>
<button
v-else-if="$auth.user"
class="iconified-button"
@click="$refs.modal_report.show()"
>
<ReportIcon aria-hidden="true" />
Report
</button>
<a v-else class="iconified-button" :href="getAuthUrl()" rel="noopener nofollow">
<ReportIcon aria-hidden="true" />
Report
</a>
</div>
<template v-if="isEditing">
<div class="inputs universal-labels">
<label for="user-username"><span class="label__title">Username</span></label>
<input id="user-username" v-model="user.username" maxlength="39" type="text" />
<label for="user-bio"><span class="label__title">Bio</span></label>
<div class="textarea-wrapper">
<textarea id="user-bio" v-model="user.bio" maxlength="160" />
</div>
</div>
<div class="button-group">
<button
class="iconified-button"
@click="
() => {
isEditing = false
user = JSON.parse(JSON.stringify($auth.user))
previewImage = null
icon = null
}
"
>
<CrossIcon /> Cancel
</button>
<button class="iconified-button brand-button" @click="saveChanges">
<SaveIcon /> Save
</button>
</div>
</template>
<template v-else>
<div class="sidebar__item">
<Badge v-if="$tag.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">
<span class="primary-stat__counter">{{ sumDownloads }}</span>
downloads
</div>
</div>
<div class="primary-stat">
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<span class="primary-stat__counter">{{ sumFollows }}</span>
followers of projects
</div>
</div>
<div class="stats-block__item secondary-stat">
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
<span
v-tooltip="$dayjs(user.created).format('MMMM D, YYYY [at] h:mm:ss A')"
class="secondary-stat__text date"
>
Joined {{ fromNow(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"> User ID: <CopyCode :text="user.id" /> </span>
</div>
<a
v-if="githubUrl"
:href="githubUrl"
:target="$external()"
rel="noopener noreferrer nofollow"
class="sidebar__item github-button iconified-button"
>
<GitHubIcon aria-hidden="true" />
View GitHub profile
</a>
</template>
</div>
</div>
<div class="normal-page__content">
<Promotion />
<nav class="navigation-card">
<NavRow
:links="[
{
label: 'all',
href: `/user/${user.username}`,
},
...projectTypes.map((x) => {
return {
label: $formatProjectType(x) + 's',
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 />
Manage projects
</NuxtLink>
<button
v-tooltip="$capitalizeString($cosmetics.searchDisplayMode.user) + ' view'"
:aria-label="$capitalizeString($cosmetics.searchDisplayMode.user) + ' view'"
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"
class="project-list"
:class="'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
.slice()
.sort((a, b) => b.featured - a.featured)
.map((x) => x.url)[0]
"
: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 || $tag.staffRoles.includes($auth.user.role))
? project.status
: null
"
:type="project.project_type"
:color="project.color"
/>
</div>
<div v-else class="error">
<UpToDate class="icon" /><br />
<span v-if="$auth.user && $auth.user.id === user.id" class="text">
You don't have any projects.<br />
Would you like to
<a class="link" @click.prevent="$refs.modal_creation.show()"> create one</a>?
</span>
<span v-else class="text">This user has no projects!</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import ProjectCard from '~/components/ui/ProjectCard'
import Badge from '~/components/ui/Badge'
import Promotion from '~/components/ads/Promotion'
import GitHubIcon from '~/assets/images/utils/github.svg'
import ReportIcon from '~/assets/images/utils/report.svg'
import SunriseIcon from '~/assets/images/utils/sunrise.svg'
import DownloadIcon from '~/assets/images/utils/download.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg'
import UserIcon from '~/assets/images/utils/user.svg'
import EditIcon from '~/assets/images/utils/edit.svg'
import HeartIcon from '~/assets/images/utils/heart.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import GridIcon from '~/assets/images/utils/grid.svg'
import ListIcon from '~/assets/images/utils/list.svg'
import ImageIcon from '~/assets/images/utils/image.svg'
import UploadIcon from '~/assets/images/utils/upload.svg'
import FileInput from '~/components/ui/FileInput'
import ModalReport from '~/components/ui/ModalReport'
import ModalCreation from '~/components/ui/ModalCreation'
import NavRow from '~/components/ui/NavRow'
import CopyCode from '~/components/ui/CopyCode'
import Avatar from '~/components/ui/Avatar'
const data = useNuxtApp()
const route = useRoute()
let user, projects
try {
;[{ data: user }, { data: projects }] = await Promise.all([
useAsyncData(`user/${route.params.id}`, () =>
useBaseFetch(`user/${route.params.id}`, data.$defaultHeaders())
),
useAsyncData(
`user/${route.params.id}/projects`,
() => useBaseFetch(`user/${route.params.id}/projects`, data.$defaultHeaders()),
{
transform: (projects) => {
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
project.project_type = data.$getProjectTypeForUrl(
project.project_type,
project.categories
)
}
return projects
},
}
),
])
} catch {
throw createError({
fatal: true,
statusCode: 404,
message: 'User not found',
})
}
if (!user.value) {
throw createError({
fatal: true,
statusCode: 404,
message: 'User not found',
})
}
let githubUrl
try {
const githubUser = await $fetch(`https://api.github.com/user/` + user.value.github_id)
githubUrl = ref(githubUser.html_url)
} catch {}
if (user.value.username !== route.params.id) {
await navigateTo(`/user/${user.value.username}`, { redirectCode: 301 })
}
const metaDescription = ref(
user.value.bio
? `${user.value.bio} - Download ${user.value.username}'s projects on Modrinth`
: `Download ${user.value.username}'s projects on Modrinth`
)
const projectTypes = computed(() => {
const obj = {}
for (const project of projects.value) {
obj[project.project_type] = true
}
return Object.keys(obj)
})
const sumDownloads = computed(() => {
let sum = 0
for (const project of projects.value) {
sum += project.downloads
}
return data.$formatNumber(sum)
})
const sumFollows = computed(() => {
let sum = 0
for (const project of projects.value) {
sum += project.followers
}
return data.$formatNumber(sum)
})
const isEditing = ref(false)
const icon = shallowRef(null)
const previewImage = shallowRef(null)
function showPreviewImage(files) {
const reader = new FileReader()
icon.value = files[0]
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
async function saveChanges() {
startLoading()
try {
if (icon.value) {
await useBaseFetch(
`user/${data.$auth.user.id}/icon?ext=${
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
}`,
{
method: 'PATCH',
body: icon.value,
...data.$defaultHeaders(),
}
)
}
const reqData = {
email: user.value.email,
bio: user.value.bio,
}
if (user.value.username !== data.$auth.user.username) {
reqData.username = user.value.username
}
await useBaseFetch(`user/${data.$auth.user.id}`, {
method: 'PATCH',
body: reqData,
...data.$defaultHeaders(),
})
await useAuth(data.$auth.token)
isEditing.value = false
} catch (err) {
console.error(err)
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
function cycleSearchDisplayMode() {
data.$cosmetics.searchDisplayMode.user = data.$cycleValue(
data.$cosmetics.searchDisplayMode.user,
data.$tag.projectViewModes
)
saveCosmetics()
}
</script>
<script>
export default defineNuxtComponent({
methods: {},
})
</script>
<style lang="scss" scoped>
.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;
}
.github-button {
display: inline-flex;
}
.inputs {
margin-bottom: 1rem;
input {
margin-top: 0.5rem;
width: 100%;
}
label {
margin-bottom: 0;
}
}
.button-group:first-child {
margin-left: auto;
}
.textarea-wrapper {
height: 10rem;
}
</style>

View File

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

View File

@@ -1,637 +0,0 @@
<template>
<div>
<ModalCreation ref="modal_creation" />
<ModalReport ref="modal_report" :item-id="user.id" item-type="user" />
<div class="user-header-wrapper">
<div class="user-header">
<Avatar
:src="previewImage ? previewImage : 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">
<FileInput
v-if="isEditing"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload avatar"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<button
v-else-if="$auth.user && $auth.user.id === user.id"
class="iconified-button"
@click="isEditing = true"
>
<EditIcon />
Edit
</button>
<button
v-else-if="$auth.user"
class="iconified-button"
@click="$refs.modal_report.show()"
>
<ReportIcon aria-hidden="true" />
Report
</button>
<a
v-else
class="iconified-button"
:href="authUrl"
rel="noopener noreferrer nofollow"
>
<ReportIcon aria-hidden="true" />
Report
</a>
</div>
<template v-if="isEditing">
<div class="inputs universal-labels">
<label for="user-username"
><span class="label__title">Username</span></label
>
<input
id="user-username"
v-model="user.username"
maxlength="39"
type="text"
/>
<label for="user-bio"
><span class="label__title">Bio</span></label
>
<div class="textarea-wrapper">
<textarea
id="user-bio"
v-model="user.bio"
maxlength="160"
></textarea>
</div>
</div>
<div class="button-group">
<button
class="iconified-button"
@click="
isEditing = false
user = JSON.parse(JSON.stringify($auth.user))
previewImage = null
icon = null
"
>
<CrossIcon /> Cancel
</button>
<button
class="iconified-button brand-button"
@click="saveChanges"
>
<SaveIcon /> Save
</button>
</div>
</template>
<template v-else>
<div class="sidebar__item">
<Badge
v-if="$tag.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">
<span class="primary-stat__counter">{{ sumDownloads() }}</span>
downloads
</div>
</div>
<div class="primary-stat">
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<span class="primary-stat__counter">{{ sumFollows() }}</span>
followers of projects
</div>
</div>
<div class="stats-block__item secondary-stat">
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
<span
v-tooltip="
$dayjs(user.created).format('MMMM D, YYYY [at] h:mm:ss A')
"
class="secondary-stat__text date"
>
Joined {{ $dayjs(user.created).fromNow() }}
</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">
User ID: <CopyCode :text="user.id" />
</span>
</div>
<a
v-if="githubUrl"
:href="githubUrl"
:target="$external()"
rel="noopener noreferrer nofollow"
class="sidebar__item github-button iconified-button"
>
<GitHubIcon aria-hidden="true" />
View GitHub profile
</a>
</template>
</div>
</div>
<div class="normal-page__content">
<Advertisement type="banner" small-screen="square" />
<nav class="navigation-card">
<NavRow
query="type"
:links="[
{
label: 'all',
href: '',
},
...projectTypes.map((x) => {
return {
label: $formatProjectType(x) + 's',
href: x,
}
}),
]"
/>
<div class="input-group">
<NuxtLink
v-if="$auth.user && $auth.user.id === user.id"
class="iconified-button"
to="/dashboard/projects"
>
<SettingsIcon />
Manage projects
</NuxtLink>
<button
v-tooltip="
$capitalizeString($cosmetics.searchDisplayMode.user) + ' view'
"
:aria-label="
$capitalizeString($cosmetics.searchDisplayMode.user) + ' view'
"
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"
class="project-list"
:class="'display-mode--' + $cosmetics.searchDisplayMode.user"
>
<ProjectCard
v-for="project in ($route.query.type !== undefined
? projects.filter((x) => x.project_type === $route.query.type)
: 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
.slice()
.sort((a, b) => b.featured - a.featured)
.map((x) => x.url)[0]
"
: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 ||
$tag.staffRoles.includes($auth.user.role))
? project.status
: null
"
:type="project.project_type"
:color="project.color"
/>
</div>
<div v-else class="error">
<UpToDate class="icon" /><br />
<span v-if="$auth.user && $auth.user.id === user.id" class="text">
You don't have any projects.<br />
Would you like to
<a class="link" @click.prevent="$refs.modal_creation.show()">
create one</a
>?
</span>
<span v-else class="text">This user has no projects!</span>
</div>
</div>
</div>
</div>
</template>
<script>
import ProjectCard from '~/components/ui/ProjectCard'
import Badge from '~/components/ui/Badge'
import Advertisement from '~/components/ads/Advertisement'
import GitHubIcon from '~/assets/images/utils/github.svg?inline'
import ReportIcon from '~/assets/images/utils/report.svg?inline'
import SunriseIcon from '~/assets/images/utils/sunrise.svg?inline'
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import GridIcon from '~/assets/images/utils/grid.svg?inline'
import ListIcon from '~/assets/images/utils/list.svg?inline'
import ImageIcon from '~/assets/images/utils/image.svg?inline'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import FileInput from '~/components/ui/FileInput'
import ModalReport from '~/components/ui/ModalReport'
import ModalCreation from '~/components/ui/ModalCreation'
import NavRow from '~/components/ui/NavRow'
import CopyCode from '~/components/ui/CopyCode'
import Avatar from '~/components/ui/Avatar'
export default {
auth: false,
components: {
Avatar,
CopyCode,
NavRow,
ModalCreation,
ModalReport,
FileInput,
ProjectCard,
SunriseIcon,
DownloadIcon,
GitHubIcon,
ReportIcon,
Badge,
SettingsIcon,
UpToDate,
UserIcon,
EditIcon,
Advertisement,
HeartIcon,
CrossIcon,
SaveIcon,
GridIcon,
ListIcon,
ImageIcon,
UploadIcon,
},
async asyncData(data) {
try {
const [user, projects] = (
await Promise.all([
data.$axios.get(`user/${data.params.id}`, data.$defaultHeaders()),
data.$axios.get(
`user/${data.params.id}/projects`,
data.$defaultHeaders()
),
])
).map((it) => it.data)
if (user.username !== data.params.id) {
data.redirect(301, `/user/${user.username}`)
return
}
let gitHubUser = {}
let versions = []
try {
const [gitHubUserData, versionsData] = (
await Promise.all([
data.$axios.get(`https://api.github.com/user/` + user.github_id),
data.$axios.get(
`versions?ids=${JSON.stringify(
[].concat.apply(
[],
projects.map((x) => x.versions)
)
)}`
),
])
).map((it) => it.data)
gitHubUser = gitHubUserData
versions = versionsData
} catch {}
for (const version of versions) {
const projectIndex = projects.findIndex(
(x) => x.id === version.project_id
)
if (projects[projectIndex].loaders) {
for (const loader of version.loaders) {
if (!projects[projectIndex].loaders.includes(loader)) {
projects[projectIndex].loaders.push(loader)
}
}
} else {
projects[projectIndex].loaders = version.loaders
}
}
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
project.project_type = data.$getProjectTypeForUrl(
project.project_type,
project.categories
)
}
return {
user,
projects,
githubUrl: gitHubUser.html_url,
}
} catch {
data.error({
statusCode: 404,
message: 'User not found',
})
}
},
data() {
return {
isEditing: false,
icon: null,
previewImage: null,
}
},
head() {
const description = this.user.bio
? `${this.user.bio} - Download ${this.user.username}'s projects on Modrinth`
: `Download ${this.user.username}'s projects on Modrinth`
return {
title: this.user.username + ' - Modrinth',
meta: [
{
hid: 'og:type',
name: 'og:type',
content: 'website',
},
{
hid: 'og:title',
name: 'og:title',
content: this.user.username,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: description,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: `${this.user.bio} - Download ${this.user.username}'s projects on Modrinth`,
},
{
hid: 'og:image',
name: 'og:image',
content:
this.user.avatar_url || 'https://cdn.modrinth.com/placeholder.png',
},
],
}
},
computed: {
authUrl() {
return `${process.env.authURLBase}auth/init?url=${process.env.domain}${this.$route.path}`
},
projectTypes() {
const obj = {}
for (const project of this.projects) {
obj[project.project_type] = true
}
return Object.keys(obj)
},
},
methods: {
sumDownloads() {
let sum = 0
for (const projects of this.projects) {
sum += projects.downloads
}
return this.$formatNumber(sum)
},
sumFollows() {
let sum = 0
for (const projects of this.projects) {
sum += projects.followers
}
return this.$formatNumber(sum)
},
showPreviewImage(files) {
const reader = new FileReader()
this.icon = files[0]
reader.readAsDataURL(this.icon)
reader.onload = (event) => {
this.previewImage = event.target.result
}
},
async saveChanges() {
this.$nuxt.$loading.start()
try {
if (this.icon) {
await this.$axios.patch(
`user/${this.$auth.user.id}/icon?ext=${
this.icon.type.split('/')[this.icon.type.split('/').length - 1]
}`,
this.icon,
this.$defaultHeaders()
)
}
const data = {
email: this.user.email,
bio: this.user.bio,
}
if (this.user.username !== this.$auth.user.username) {
data.username = this.user.username
}
await this.$axios.patch(
`user/${this.$auth.user.id}`,
data,
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
})
this.isEditing = false
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
async cycleSearchDisplayMode() {
const value = this.$cosmetics.searchDisplayMode.user
const newValue = this.$cycleValue(value, this.$tag.projectViewModes)
await this.$store.dispatch('cosmetics/saveSearchDisplayMode', {
projectType: 'user',
mode: newValue,
$cookies: this.$cookies,
})
},
},
}
</script>
<style lang="scss" scoped>
.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;
}
.github-button {
display: inline-flex;
}
.inputs {
margin-bottom: 1rem;
input {
margin-top: 0.5rem;
width: 100%;
}
label {
margin-bottom: 0;
}
}
.button-group:first-child {
margin-left: auto;
}
.textarea-wrapper {
height: 10rem;
}
</style>