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

1323
pages/[type]/[id].vue Normal file

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

@@ -0,0 +1,816 @@
<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"
:header="editIndex === -1 ? 'Upload gallery image' : 'Edit gallery item'"
>
<div class="modal-gallery universal-labels">
<div class="gallery-file-input">
<div class="file-header">
<ImageIcon />
<strong>{{ editFile ? editFile.name : 'Current image' }}</strong>
<FileInput
v-if="editIndex === -1"
class="iconified-button raised-button"
prompt="Replace"
:accept="acceptFileTypes"
:max-size="524288000"
should-always-reset
@change="
(x) => {
editFile = x[0]
showPreviewImage()
}
"
>
<TransferIcon />
</FileInput>
</div>
<img
:src="
previewImage
? previewImage
: project.gallery[editIndex] && project.gallery[editIndex].url
? project.gallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
alt="gallery-preview"
/>
</div>
<label for="gallery-image-title">
<span class="label__title">Title</span>
</label>
<input
id="gallery-image-title"
v-model="editTitle"
type="text"
maxlength="64"
placeholder="Enter title..."
/>
<label for="gallery-image-desc">
<span class="label__title">Description</span>
</label>
<div class="textarea-wrapper">
<textarea
id="gallery-image-desc"
v-model="editDescription"
maxlength="255"
placeholder="Enter description..."
/>
</div>
<label for="gallery-image-ordering">
<span class="label__title">Order Index</span>
</label>
<input
id="gallery-image-ordering"
v-model="editOrder"
type="number"
placeholder="Enter order index..."
/>
<label for="gallery-image-featured">
<span class="label__title">Featured</span>
<span class="label__description">
A featured gallery image shows up in search and your project card. Only one gallery
image can be featured.
</span>
</label>
<button
v-if="!editFeatured"
id="gallery-image-featured"
class="iconified-button"
@click="editFeatured = true"
>
<StarIcon aria-hidden="true" />
Feature image
</button>
<button
v-else
id="gallery-image-featured"
class="iconified-button"
@click="editFeatured = false"
>
<StarIcon fill="currentColor" aria-hidden="true" />
Unfeature image
</button>
<div class="button-group">
<button class="iconified-button" @click="$refs.modal_edit_item.hide()">
<CrossIcon />
Cancel
</button>
<button
v-if="editIndex === -1"
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="createGalleryItem"
>
<PlusIcon />
Add gallery image
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="editGalleryItem"
>
<SaveIcon />
Save changes
</button>
</div>
</div>
</Modal>
<ModalConfirm
v-if="$auth.user && currentMember"
ref="modal_confirm"
title="Are you sure you want to delete this gallery image?"
description="This will remove this gallery image forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteGalleryImage"
/>
<div
v-if="expandedGalleryItem != null"
class="expanded-image-modal"
@click="expandedGalleryItem = null"
>
<div class="content">
<img
class="image"
:class="{ 'zoomed-in': zoomedIn }"
:src="
expandedGalleryItem.url
? expandedGalleryItem.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop=""
/>
<div class="floating" @click.stop="">
<div class="text">
<h2 v-if="expandedGalleryItem.title">
{{ expandedGalleryItem.title }}
</h2>
<p v-if="expandedGalleryItem.description">
{{ expandedGalleryItem.description }}
</p>
</div>
<div class="controls">
<div class="buttons">
<button class="close circle-button" @click="expandedGalleryItem = null">
<CrossIcon aria-hidden="true" />
</button>
<a
class="open circle-button"
target="_blank"
:href="
expandedGalleryItem.url
? expandedGalleryItem.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
>
<ExternalIcon aria-hidden="true" />
</a>
<button class="circle-button" @click="zoomedIn = !zoomedIn">
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
<RightArrowIcon aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="currentMember" class="card header-buttons">
<FileInput
:max-size="524288000"
:accept="acceptFileTypes"
prompt="Upload an image"
class="brand-button iconified-button"
@change="handleFiles"
>
<UploadIcon />
</FileInput>
<span class="indicator">
<InfoIcon /> Click to choose an image or drag one onto this page
</span>
<DropArea :accept="acceptFileTypes" @change="handleFiles" />
</div>
<div class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
:alt="item.title ? item.title : 'gallery-image'"
/>
</a>
<div class="gallery-body">
<div class="gallery-info">
<h2 v-if="item.title">
{{ item.title }}
</h2>
<p v-if="item.description">
{{ item.description }}
</p>
</div>
</div>
<div class="gallery-bottom">
<div class="gallery-created">
<CalendarIcon />
{{ $dayjs(item.created).format('MMMM D, YYYY') }}
</div>
<div v-if="currentMember" class="gallery-buttons input-group">
<button
class="iconified-button"
@click="
() => {
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
}
"
>
<EditIcon />
Edit
</button>
<button
class="iconified-button"
@click="
() => {
deleteIndex = index
$refs.modal_confirm.show()
}
"
>
<TrashIcon />
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
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 defineNuxtComponent({
components: {
CalendarIcon,
PlusIcon,
EditIcon,
TrashIcon,
SaveIcon,
StarIcon,
CrossIcon,
RightArrowIcon,
LeftArrowIcon,
ExternalIcon,
ExpandIcon,
ContractIcon,
UploadIcon,
InfoIcon,
ImageIcon,
TransferIcon,
ModalConfirm,
Modal,
FileInput,
DropArea,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
},
data() {
return {
expandedGalleryItem: null,
expandedGalleryIndex: 0,
zoomedIn: false,
deleteIndex: -1,
editIndex: -1,
editTitle: '',
editDescription: '',
editFeatured: false,
editOrder: null,
editFile: null,
previewImage: null,
shouldPreventActions: false,
metaDescription: `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`,
}
},
computed: {
acceptFileTypes() {
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
},
},
mounted() {
this._keyListener = function (e) {
if (this.expandedGalleryItem) {
e.preventDefault()
if (e.key === 'Escape') {
this.expandedGalleryItem = null
} else if (e.key === 'ArrowLeft') {
this.previousImage()
} else if (e.key === 'ArrowRight') {
this.nextImage()
}
}
}
document.addEventListener('keydown', this._keyListener.bind(this))
},
methods: {
nextImage() {
this.expandedGalleryIndex++
if (this.expandedGalleryIndex >= this.project.gallery.length) {
this.expandedGalleryIndex = 0
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
previousImage() {
this.expandedGalleryIndex--
if (this.expandedGalleryIndex < 0) {
this.expandedGalleryIndex = this.project.gallery.length - 1
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
expandImage(item, index) {
this.expandedGalleryItem = item
this.expandedGalleryIndex = index
this.zoomedIn = false
},
resetEdit() {
this.editIndex = -1
this.editTitle = ''
this.editDescription = ''
this.editFeatured = false
this.editOrder = null
this.editFile = null
this.previewImage = null
},
handleFiles(files) {
this.resetEdit()
this.editFile = files[0]
this.showPreviewImage()
this.$refs.modal_edit_item.show()
},
showPreviewImage() {
const reader = new FileReader()
if (this.editFile instanceof Blob) {
reader.readAsDataURL(this.editFile)
reader.onload = (event) => {
this.previewImage = event.target.result
}
}
},
async createGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?ext=${
this.editFile
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
: null
}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'POST',
body: this.editFile,
...this.$defaultHeaders(),
})
await this.updateProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async editGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url
)}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'PATCH',
...this.$defaultHeaders(),
})
await this.updateProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async deleteGalleryImage() {
startLoading()
try {
await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url
)}`,
{
method: 'DELETE',
...this.$defaultHeaders(),
}
)
await this.updateProject()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
},
async updateProject() {
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>
.header-buttons {
display: flex;
align-items: center;
gap: 1rem;
.indicator {
display: flex;
gap: 0.5ch;
align-items: center;
color: var(--color-text-inactive);
}
}
.expanded-image-modal {
position: fixed;
z-index: 20;
overflow: auto;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #000000;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
.content {
position: relative;
width: calc(100vw - 2 * var(--spacing-card-lg));
height: calc(100vh - 2 * var(--spacing-card-lg));
.circle-button {
padding: 0.5rem;
line-height: 1;
display: flex;
max-width: 2rem;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-max);
margin: 0;
box-shadow: inset 0px -1px 1px rgb(17 24 39 / 10%);
&:not(:last-child) {
margin-right: 0.5rem;
}
&:hover {
background-color: var(--color-button-bg-hover) !important;
svg {
color: var(--color-button-text-hover) !important;
}
}
&:active {
background-color: var(--color-button-bg-active) !important;
svg {
color: var(--color-button-text-active) !important;
}
}
svg {
height: 1rem;
width: 1rem;
}
}
.image {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: calc(100vw - 2 * var(--spacing-card-lg));
max-height: calc(100vh - 2 * var(--spacing-card-lg));
border-radius: var(--size-rounded-card);
&.zoomed-in {
object-fit: cover;
width: auto;
height: calc(100vh - 2 * var(--spacing-card-lg));
max-width: calc(100vw - 2 * var(--spacing-card-lg));
}
}
.floating {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-card-md);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-card-sm);
transition: opacity 0.25s ease-in-out;
opacity: 1;
padding: 2rem 2rem 0 2rem;
&:not(&:hover) {
opacity: 0.4;
.text {
transform: translateY(2.5rem) scale(0.8);
opacity: 0;
}
.controls {
transform: translateY(0.25rem) scale(0.9);
}
}
.text {
display: flex;
flex-direction: column;
max-width: 40rem;
transition: opacity 0.25s ease-in-out, transform 0.25s ease-in-out;
text-shadow: 1px 1px 10px #000000d4;
margin-bottom: 0.25rem;
gap: 0.5rem;
h2 {
color: var(--dark-color-text-dark);
font-size: 1.25rem;
text-align: center;
margin: 0;
}
p {
color: var(--dark-color-text);
margin: 0;
}
}
.controls {
background-color: var(--color-raised-bg);
padding: var(--spacing-card-md);
border-radius: var(--size-rounded-card);
transition: opacity 0.25s ease-in-out, transform 0.25s ease-in-out;
}
}
}
}
.buttons {
display: flex;
button {
margin-right: 0.5rem;
}
}
.items {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
grid-gap: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr 1fr;
}
}
.gallery-item {
display: flex;
flex-direction: column;
padding: 0;
img {
width: 100%;
margin-top: 0;
margin-bottom: 0;
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
min-height: 10rem;
object-fit: cover;
}
.gallery-body {
flex-grow: 1;
width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md);
.gallery-info {
h2 {
margin-bottom: 0.5rem;
}
p {
margin: 0 0 0.5rem 0;
}
}
}
.gallery-thumbnail {
cursor: pointer;
img {
transition: filter 0.25s ease-in-out;
&:hover {
filter: brightness(0.7);
}
}
}
.gallery-bottom {
width: calc(100% - 2 * var(--spacing-card-md));
padding: 0 var(--spacing-card-md) var(--spacing-card-sm) var(--spacing-card-md);
.gallery-created {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
color: var(--color-icon);
svg {
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
}
}
.gallery-buttons {
display: flex;
}
.columns {
margin-bottom: 0.5rem;
}
}
}
.modal-gallery {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
.gallery-file-input {
.file-header {
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--color-button-bg);
padding: var(--spacing-card-md);
svg {
min-width: 1rem;
}
strong {
word-wrap: anywhere;
}
.iconified-button {
margin-left: auto;
}
}
img {
border-radius: 0 0 var(--size-rounded-card) var(--size-rounded-card);
width: 100%;
height: auto;
max-height: 15rem;
object-fit: contain;
background-color: #000000;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style>

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

@@ -0,0 +1,136 @@
<template>
<div>
<section class="universal-card">
<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
<a
class="text-link"
href="https://guides.github.com/features/mastering-markdown/"
target="_blank"
rel="noopener"
>Markdown</a
>. 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>
for the full requirements.
</span>
</span>
</label>
<Chips v-model="bodyViewMode" :items="['source', 'preview']" />
<div v-if="bodyViewMode === 'source'" class="resizable-textarea-wrapper">
<textarea
id="project-description"
v-model="description"
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
/>
</div>
<div
v-else-if="bodyViewMode === 'preview'"
class="markdown-body"
v-html="description ? renderHighlightedString(description) : 'No body specified.'"
/>
<div class="input-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Chips from '~/components/ui/Chips'
import SaveIcon from '~/assets/images/utils/save.svg'
import { renderHighlightedString } from '~/helpers/highlight'
export default defineNuxtComponent({
components: {
Chips,
SaveIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
allMembers: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
return {
description: this.project.body,
bodyViewMode: 'source',
}
},
computed: {
patchData() {
const data = {}
if (this.description !== this.project.body) {
data.body = this.description
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
created() {
this.EDIT_BODY = 1 << 3
},
methods: {
renderHighlightedString,
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
})
</script>
<style lang="scss" scoped>
.resizable-textarea-wrapper textarea {
min-height: 40rem;
}
.markdown-body {
margin-bottom: var(--spacing-card-md);
}
</style>

View File

@@ -0,0 +1,397 @@
<template>
<div>
<ModalConfirm
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
:has-to-type="true"
:confirmation-text="project.title"
proceed-label="Delete"
@proceed="deleteProject"
/>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
<label for="project-name">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon />
Remove icon
</button>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="project-name"
v-model="name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
/>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/mod/</div>
<input
id="project-slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<template
v-if="
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<Multiselect
id="project-env-client"
v-model="clientSide"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Multiselect
id="project-env-server"
v-model="serverSide"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
</template>
<div class="adjacent-input">
<label for="project-visibility">
<span class="label__title">Visibility</span>
<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.
</span>
</label>
<Multiselect
id="project-visibility"
v-model="visibility"
placeholder="Select one"
:options="$tag.approvedStatuses"
:custom-label="(value) => $formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete project</span>
</h3>
</div>
<p>
Removes your project from Modrinth's servers and search. Clicking on this will delete your
project, so be extra careful!
</p>
<button
type="button"
class="iconified-button danger-button"
:disabled="!hasDeletePermission"
@click="$refs.modal_confirm.show()"
>
<TrashIcon />
Delete project
</button>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
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'
import SaveIcon from '~/assets/images/utils/save.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
export default defineNuxtComponent({
components: {
Avatar,
ModalConfirm,
FileInput,
Multiselect,
UploadIcon,
SaveIcon,
TrashIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
patchIcon: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch icon function not found',
type: 'error',
})
}
},
},
updateIcon: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Update icon function not found',
type: 'error',
})
}
},
},
},
data() {
return {
name: this.project.title,
slug: this.project.slug,
summary: this.project.description,
icon: null,
previewImage: null,
clientSide: this.project.client_side,
serverSide: this.project.server_side,
deletedIcon: false,
visibility: this.$tag.approvedStatuses.includes(this.project.status)
? this.project.status
: this.project.requested_status,
}
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
},
hasDeletePermission() {
const DELETE_PROJECT = 1 << 7
return (this.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
},
sideTypes() {
return ['required', 'optional', 'unsupported']
},
patchData() {
const data = {}
if (this.name !== this.project.title) {
data.title = this.name.trim()
}
if (this.slug !== this.project.slug) {
data.slug = this.slug.trim()
}
if (this.summary !== this.project.description) {
data.description = this.summary.trim()
}
if (this.clientSide !== this.project.client_side) {
data.client_side = this.clientSide
}
if (this.serverSide !== this.project.server_side) {
data.server_side = this.serverSide
}
if (this.$tag.approvedStatuses.includes(this.project.status)) {
if (this.visibility !== this.project.status) {
data.status = this.visibility
}
} else if (this.visibility !== this.project.requested_status) {
data.requested_status = this.visibility
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0 || this.deletedIcon || this.icon
},
},
methods: {
async saveChanges() {
if (this.hasChanges) {
await this.patchProject(this.patchData)
}
if (this.deletedIcon) {
await this.deleteIcon()
this.deletedIcon = false
} else if (this.icon) {
await this.patchIcon(this.icon)
this.icon = null
}
},
showPreviewImage(files) {
const reader = new FileReader()
this.icon = files[0]
this.deletedIcon = false
reader.readAsDataURL(this.icon)
reader.onload = (event) => {
this.previewImage = event.target.result
}
},
async deleteProject() {
await useBaseFetch(`project/${this.project.id}`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await initUserProjects()
await this.$router.push('/dashboard/projects')
this.$notify({
group: 'main',
title: 'Project deleted',
text: 'Your project has been deleted.',
type: 'success',
})
},
markIconForDeletion() {
this.deletedIcon = true
this.icon = null
this.previewImage = null
},
async deleteIcon() {
await useBaseFetch(`project/${this.project.id}/icon`, {
method: 'DELETE',
...this.$defaultHeaders(),
})
await this.updateIcon()
this.$notify({
group: 'main',
title: 'Project icon removed',
text: "Your project's icon has been removed.",
type: 'success',
})
},
},
})
</script>
<style lang="scss" scoped>
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div>
<section class="universal-card">
<div class="adjacent-input">
<label for="license-multiselect">
<span class="label__title size-card-header">License</span>
<span class="label__description">
It is very important to choose a proper license for your
{{ $formatProjectType(project.project_type).toLowerCase() }}. You may choose one from
our list or provide a custom license. You may also provide a custom URL to your chosen
license; otherwise, the license text will be displayed.
<span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
Enter a valid
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>
in the marked area. If your license does not have a SPDX identifier (for example, if
you created the license yourself or if the license is Minecraft-specific), simply
check the box and enter the name of the license instead.
</span>
<span class="label__subdescription">
Confused? See our
<a
href="https://blog.modrinth.com/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide</a
>
for more information.
</span>
</span>
</label>
<div class="input-stack">
<Multiselect
id="license-multiselect"
v-model="license"
placeholder="Select license..."
track-by="short"
label="friendly"
:options="defaultLicenses"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:class="{
'known-error': license.short === '' && showKnownErrors,
}"
:disabled="!hasPermission"
/>
<Checkbox
v-if="license.requiresOnlyOrLater"
v-model="allowOrLater"
:disabled="!hasPermission"
description="Allow later editions of this license"
>
Allow later editions of this license
</Checkbox>
<Checkbox
v-if="license.friendly === 'Custom'"
v-model="nonSpdxLicense"
:disabled="!hasPermission"
description="License does not have a SPDX identifier"
>
License does not have a SPDX identifier
</Checkbox>
<input
v-if="license.friendly === 'Custom'"
v-model="license.short"
type="text"
maxlength="2048"
:placeholder="nonSpdxLicense ? 'License name' : 'SPDX identifier'"
:class="{
'known-error': license.short === '' && showKnownErrors,
}"
:disabled="!hasPermission"
/>
<input
v-model="licenseUrl"
type="url"
maxlength="2048"
placeholder="License URL (optional)"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
/>
</div>
</div>
<div class="input-stack">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import Checkbox from '~/components/ui/Checkbox'
import SaveIcon from '~/assets/images/utils/save.svg'
export default defineNuxtComponent({
components: {
Multiselect,
Checkbox,
SaveIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
return {
licenseUrl: '',
license: { friendly: '', short: '', requiresOnlyOrLater: false },
allowOrLater: false,
nonSpdxLicense: false,
showKnownErrors: false,
}
},
async setup(props) {
const defaultLicenses = shallowRef([
{ friendly: 'Custom', short: '' },
{
friendly: 'All Rights Reserved/No License',
short: 'All-Rights-Reserved',
},
{ friendly: 'Apache License 2.0', short: 'Apache-2.0' },
{
friendly: 'BSD 2-Clause "Simplified" License',
short: 'BSD-2-Clause',
},
{
friendly: 'BSD 3-Clause "New" or "Revised" License',
short: 'BSD-3-Clause',
},
{
friendly: 'CC Zero (Public Domain equivalent)',
short: 'CC0-1.0',
},
{ friendly: 'CC-BY 4.0', short: 'CC-BY-4.0' },
{
friendly: 'CC-BY-SA 4.0',
short: 'CC-BY-SA-4.0',
},
{
friendly: 'CC-BY-NC 4.0',
short: 'CC-BY-NC-4.0',
},
{
friendly: 'CC-BY-NC-SA 4.0',
short: 'CC-BY-NC-SA-4.0',
},
{
friendly: 'CC-BY-ND 4.0',
short: 'CC-BY-ND-4.0',
},
{
friendly: 'CC-BY-NC-ND 4.0',
short: 'CC-BY-NC-ND-4.0',
},
{
friendly: 'GNU Affero General Public License v3',
short: 'AGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v2.1',
short: 'LGPL-2.1',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v3',
short: 'LGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v2',
short: 'GPL-2.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v3',
short: 'GPL-3.0',
requiresOnlyOrLater: true,
},
{ friendly: 'ISC License', short: 'ISC' },
{ friendly: 'MIT License', short: 'MIT' },
{ friendly: 'Mozilla Public License 2.0', short: 'MPL-2.0' },
{ friendly: 'zlib License', short: 'Zlib' },
])
const licenseUrl = ref(props.project.license.url)
const licenseId = props.project.license.id
const trimmedLicenseId = licenseId
.replaceAll('-only', '')
.replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '')
const license = ref(
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
friendly: 'Custom',
short: licenseId.replaceAll('LicenseRef-', ''),
}
)
if (licenseId === 'LicenseRef-Unknown') {
license.value = {
friendly: 'Unknown',
short: licenseId.replaceAll('LicenseRef-', ''),
}
}
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() {
const EDIT_DETAILS = 1 << 2
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
},
licenseId() {
let id = ''
if (
(this.nonSpdxLicense && this.license.friendly === 'Custom') ||
this.license.short === 'All-Rights-Reserved' ||
this.license.short === 'Unknown'
) {
id += 'LicenseRef-'
}
id += this.license.short
if (this.license.requiresOnlyOrLater) {
id += this.allowOrLater ? '-or-later' : '-only'
}
if (this.nonSpdxLicense && this.license.friendly === 'Custom') {
id = id.replaceAll(' ', '-')
}
return id
},
patchData() {
const data = {}
if (this.licenseId !== this.project.license.id) {
data.license_id = this.licenseId
data.license_url = this.licenseUrl ? this.licenseUrl : null
} else if (this.licenseUrl !== this.project.license.url) {
data.license_url = this.licenseUrl ? this.licenseUrl : null
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
methods: {
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,274 @@
<template>
<div>
<section class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label
id="project-issue-tracker"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker</span>
<span class="label__description">
A place for users to report bugs, issues, and concerns about your project.
</span>
</label>
<input
id="project-issue-tracker"
v-model="issuesUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-source-code"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code</span>
<span class="label__description">
A page/repository containing the source code for your project
</span>
</label>
<input
id="project-source-code"
v-model="sourceUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="project-wiki-page"
title="A page containing information, documentation, and help for the project."
>
<span class="label__title">Wiki page</span>
<span class="label__description">
A page containing information, documentation, and help for the project.
</span>
</label>
<input
id="project-wiki-page"
v-model="wikiUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server.">
<span class="label__title">Discord invite</span>
<span class="label__description"> An invitation link to your Discord server. </span>
</label>
<input
id="project-discord-invite"
v-model="discordUrl"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
/>
</div>
<span class="label">
<span class="label__title">Donation links</span>
<span class="label__description">
Add donation links for users to support you directly.
</span>
</span>
<div
v-for="(donationLink, index) in donationLinks"
:key="`donation-link-${index}`"
class="input-group donation-link-group"
>
<Multiselect
v-model="donationLink.platform"
placeholder="Select platform"
:options="$tag.donationPlatforms.map((x) => x.name)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:disabled="!hasPermission"
@update:model-value="updateDonationLinks"
/>
<input
v-model="donationLink.url"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="!hasPermission"
@input="updateDonationLinks"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import SaveIcon from '~/assets/images/utils/save.svg'
export default defineNuxtComponent({
components: {
Multiselect,
SaveIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
const donationLinks = JSON.parse(JSON.stringify(this.project.donation_urls))
donationLinks.push({
id: null,
platform: null,
url: null,
})
return {
issuesUrl: this.project.issues_url,
sourceUrl: this.project.source_url,
wikiUrl: this.project.wiki_url,
discordUrl: this.project.discord_url,
donationLinks,
}
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS
},
patchData() {
const data = {}
if (this.checkDifference(this.issuesUrl, this.project.issues_url)) {
data.issues_url = this.issuesUrl === '' ? null : this.issuesUrl.trim()
}
if (this.checkDifference(this.sourceUrl, this.project.source_url)) {
data.source_url = this.sourceUrl === '' ? null : this.sourceUrl.trim()
}
if (this.checkDifference(this.wikiUrl, this.project.wiki_url)) {
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()
}
const donationLinks = this.donationLinks.filter((link) => link.url && link.platform)
donationLinks.forEach((link) => {
link.id = this.$tag.donationPlatforms.find(
(platform) => platform.name === link.platform
).short
})
if (
donationLinks !== this.project.donation_urls &&
!(
this.project.donation_urls &&
this.project.donation_urls.length === 0 &&
donationLinks.length === 0
)
) {
data.donation_urls = donationLinks
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
methods: {
async saveChanges() {
if (this.patchData && (await this.patchProject(this.patchData))) {
this.donationLinks = JSON.parse(JSON.stringify(this.project.donation_urls))
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
}
},
updateDonationLinks() {
this.donationLinks.forEach((link) => {
if (link.url) {
const url = link.url.toLowerCase()
if (url.includes('patreon.com')) {
link.platform = 'Patreon'
} else if (url.includes('ko-fi.com')) {
link.platform = 'Ko-fi'
} else if (url.includes('paypal.com')) {
link.platform = 'Paypal'
} else if (url.includes('buymeacoffee.com')) {
link.platform = 'Buy Me a Coffee'
} else if (url.includes('github.com/sponsors')) {
link.platform = 'GitHub Sponsors'
}
}
})
if (!this.donationLinks.find((link) => !(link.url && link.platform))) {
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
}
},
checkDifference(newLink, existingLink) {
if (newLink === '' && existingLink !== null) {
return true
}
if (!newLink && !existingLink) {
return false
}
return newLink !== existingLink
},
},
})
</script>
<style lang="scss" scoped>
.donation-link-group {
input {
flex-grow: 2;
max-width: 26rem;
}
}
</style>

View File

@@ -0,0 +1,472 @@
<template>
<div>
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Manage members</span>
</h3>
</div>
<span class="label">
<span class="label__title">Invite a member</span>
<span class="label__description">
Enter the Modrinth username of the person you'd like to invite to be a member of this
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" />
<label for="username" class="hidden">Username</label>
<button class="iconified-button brand-button" @click="inviteTeamMember">
<UserPlusIcon />
Invite
</button>
</div>
</div>
<div
v-for="(member, index) in allTeamMembers"
:key="member.user.id"
class="universal-card member"
:class="{ open: openTeamMembers.includes(member.user.id) }"
>
<div class="member-header">
<div class="info">
<Avatar :src="member.avatar_url" :alt="member.username" size="sm" circle />
<div class="text">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p>{{ member.name }}</p>
</nuxt-link>
<p>{{ member.role }}</p>
</div>
</div>
<div class="side-buttons">
<Badge v-if="member.accepted" type="accepted" />
<Badge v-else type="pending" />
<button
class="square-button dropdown-icon"
@click="
openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id)
: (openTeamMembers = openTeamMembers.filter((it) => it !== member.user.id))
"
>
<DropdownIcon />
</button>
</div>
</div>
<div class="content">
<div v-if="member.oldRole !== 'Owner'" class="adjacent-input">
<label :for="`member-${allTeamMembers[index].user.username}-role`">
<span class="label__title">Role</span>
<span class="label__description">
The title of the role that this member plays for this project.
</span>
</label>
<input
:id="`member-${allTeamMembers[index].user.username}-role`"
v-model="allTeamMembers[index].role"
type="text"
:class="{ 'known-error': member.role === 'Owner' }"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/>
</div>
<div class="adjacent-input">
<label :for="`member-${allTeamMembers[index].user.username}-monetization-weight`">
<span class="label__title">Monetization weight</span>
<span class="label__description">
Relative to all other members' monetization weights, this determines what portion of
this project's revenue goes to this member.
</span>
</label>
<input
:id="`member-${allTeamMembers[index].user.username}-monetization-weight`"
v-model="allTeamMembers[index].payouts_split"
type="number"
:disabled="(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>
<template v-if="member.oldRole !== 'Owner'">
<span class="label">
<span class="label__title">Permissions</span>
</span>
<div class="permissions">
<Checkbox
:model-value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
"
label="Upload version"
@update:model-value="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
/>
<Checkbox
:model-value="(member.permissions & DELETE_VERSION) === DELETE_VERSION"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_VERSION) !== DELETE_VERSION
"
label="Delete version"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_VERSION"
/>
<Checkbox
:model-value="(member.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
label="Edit details"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_DETAILS"
/>
<Checkbox
:model-value="(member.permissions & EDIT_BODY) === EDIT_BODY"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_BODY) !== EDIT_BODY
"
label="Edit body"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_BODY"
/>
<Checkbox
:model-value="(member.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & MANAGE_INVITES) !== MANAGE_INVITES
"
label="Manage invites"
@update:model-value="allTeamMembers[index].permissions ^= MANAGE_INVITES"
/>
<Checkbox
:model-value="(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
"
label="Remove member"
@update:model-value="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
/>
<Checkbox
:model-value="(member.permissions & EDIT_MEMBER) === EDIT_MEMBER"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
label="Edit member"
@update:model-value="allTeamMembers[index].permissions ^= EDIT_MEMBER"
/>
<Checkbox
:model-value="(member.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT
"
label="Delete project"
@update:model-value="allTeamMembers[index].permissions ^= DELETE_PROJECT"
/>
<Checkbox
:model-value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
"
label="View analytics"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
/>
<Checkbox
:model-value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
"
label="View revenue"
@update:model-value="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
/>
</div>
</template>
<div class="input-group">
<button
class="iconified-button brand-button"
:disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
@click="updateTeamMember(index)"
>
<SaveIcon />
Save changes
</button>
<button
v-if="member.oldRole !== 'Owner'"
class="iconified-button danger-button"
: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"
class="iconified-button"
@click="transferOwnership(index)"
>
<TransferIcon />
Transfer ownership
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import Checkbox from '~/components/ui/Checkbox'
import Badge from '~/components/ui/Badge'
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 defineNuxtComponent({
components: {
Avatar,
DropdownIcon,
Checkbox,
Badge,
SaveIcon,
TransferIcon,
UserPlusIcon,
UserRemoveIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
allMembers: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
},
data() {
return {
currentUsername: '',
openTeamMembers: [],
allTeamMembers: this.allMembers.map((x) => {
x.oldRole = x.role
return x
}),
}
},
created() {
this.UPLOAD_VERSION = 1 << 0
this.DELETE_VERSION = 1 << 1
this.EDIT_DETAILS = 1 << 2
this.EDIT_BODY = 1 << 3
this.MANAGE_INVITES = 1 << 4
this.REMOVE_MEMBER = 1 << 5
this.EDIT_MEMBER = 1 << 6
this.DELETE_PROJECT = 1 << 7
this.VIEW_ANALYTICS = 1 << 8
this.VIEW_PAYOUTS = 1 << 9
},
methods: {
async inviteTeamMember() {
startLoading()
try {
const user = await useBaseFetch(`user/${this.currentUsername}`)
const data = {
user_id: user.id.trim(),
}
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.data.description,
type: 'error',
})
}
stopLoading()
},
async removeTeamMember(index) {
startLoading()
try {
await useBaseFetch(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
{
method: 'DELETE',
...this.$defaultHeaders(),
}
)
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
},
async updateTeamMember(index) {
startLoading()
try {
const data =
this.allTeamMembers[index].oldRole !== 'Owner'
? {
permissions: this.allTeamMembers[index].permissions,
role: this.allTeamMembers[index].role,
payouts_split: this.allTeamMembers[index].payouts_split,
}
: {
payouts_split: this.allTeamMembers[index].payouts_split,
}
await useBaseFetch(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
{
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.",
type: 'success',
})
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
},
async transferOwnership(index) {
startLoading()
try {
await useBaseFetch(`team/${this.project.team}/owner`, {
method: 'PATCH',
body: {
user_id: this.allTeamMembers[index].user.id,
},
...this.$defaultHeaders(),
})
await this.updateMembers()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
},
async updateMembers() {
this.allTeamMembers = (
await useBaseFetch(`team/${this.project.team}/members`, this.$defaultHeaders())
).map((it) => ({
avatar_url: it.user.avatar_url,
name: it.user.username,
oldRole: it.role,
...it,
}))
},
},
})
</script>
<style lang="scss" scoped>
.member {
.member-header {
display: flex;
justify-content: space-between;
.info {
display: flex;
.text {
margin: auto 0 auto 0.5rem;
font-size: var(--font-size-sm);
.name {
font-weight: bold;
}
p {
margin: 0.2rem 0;
}
}
}
.side-buttons {
display: flex;
align-items: center;
.dropdown-icon {
margin-left: 1rem;
svg {
transition: 150ms ease transform;
}
}
}
}
.content {
display: none;
flex-direction: column;
padding-top: var(--spacing-card-md);
.main-info {
margin-bottom: var(--spacing-card-lg);
}
.permissions {
margin-bottom: var(--spacing-card-md);
max-width: 45rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0.5rem;
}
}
&.open {
.member-header {
.dropdown-icon svg {
transform: rotate(180deg);
}
}
.content {
display: flex;
}
}
}
</style>

View File

@@ -0,0 +1,294 @@
<template>
<div>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Tags</span>
</h3>
</div>
<p>
Accurate tagging is important to help people find your
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div class="label">
<h4>
<span class="label__title">{{ $formatCategoryHeader(header) }}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
Select all categories that reflect the themes or function of your
{{ $formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'features'">
Select all of the features that your
{{ $formatProjectType(project.project_type).toLowerCase() }} makes use of.
</template>
<template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your
{{ $formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'performance impact'">
Select the realistic performance impact of your
{{ $formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
{{ $formatProjectType(project.project_type).toLowerCase() }} is configurable to
different levels of performance impact.
</template>
</span>
</div>
<div class="category-list input-div">
<Checkbox
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:model-value="selectedTags.includes(category)"
:description="$formatCategory(category.name)"
class="category-selector"
@update:model-value="toggleCategory(category)"
>
<div class="category-selector__label">
<div
v-if="header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
</template>
<div class="label">
<h4>
<span class="label__title"><StarIcon /> Featured tags</span>
</h4>
<span class="label__description">
You can feature up to 3 of your most relevant tags. Other tags may be promoted to featured
if you do not select all 3.
</span>
</div>
<p v-if="selectedTags.length < 1">
Select at least one category in order to feature a category.
</p>
<div class="category-list input-div">
<Checkbox
v-for="category in selectedTags"
:key="`featured-category-${category.name}`"
class="category-selector"
:model-value="featuredTags.includes(category)"
:description="$formatCategory(category.name)"
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
@update:model-value="toggleFeaturedCategory(category)"
>
<div class="category-selector__label">
<div
v-if="category.header !== 'resolutions' && category.icon"
aria-hidden="true"
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
</div>
</template>
<script>
import Checkbox from '~/components/ui/Checkbox'
import StarIcon from '~/assets/images/utils/star.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
export default defineNuxtComponent({
components: {
Checkbox,
SaveIcon,
StarIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
allMembers: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'Patch project function not found',
type: 'error',
})
}
},
},
},
data() {
return {
selectedTags: this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name))
),
featuredTags: this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name)
),
}
},
computed: {
categoryLists() {
const lists = {}
this.$sortedCategories.forEach((x) => {
if (x.project_type === this.project.actualProjectType) {
const header = x.header
if (!lists[header]) {
lists[header] = []
}
lists[header].push(x)
}
})
return lists
},
patchData() {
const data = {}
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = this.featuredTags.slice()
if (newFeaturedTags.length < 1 && this.selectedTags.length > newFeaturedTags.length) {
const nonFeaturedCategories = this.selectedTags.filter((x) => !newFeaturedTags.includes(x))
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x))
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name)
const additionalCategories = this.selectedTags
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name)
if (
categories.length !== this.project.categories.length ||
categories.some((value) => !this.project.categories.includes(value))
) {
data.categories = categories
}
if (
additionalCategories.length !== this.project.additional_categories.length ||
additionalCategories.some((value) => !this.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories
}
return data
},
hasChanges() {
return Object.keys(this.patchData).length > 0
},
},
methods: {
toggleCategory(category) {
if (this.selectedTags.includes(category)) {
this.selectedTags = this.selectedTags.filter((x) => x !== category)
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category)
}
} else {
this.selectedTags.push(category)
}
},
toggleFeaturedCategory(category) {
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category)
} else {
this.featuredTags.push(category)
}
},
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
})
</script>
<style lang="scss" scoped>
.label__title {
margin-top: var(--spacing-card-bg);
svg {
vertical-align: top;
}
}
.category-list {
column-count: 4;
column-gap: var(--spacing-card-lg);
margin-bottom: var(--spacing-card-md);
:deep(.category-selector) {
margin-bottom: 0.5rem;
.category-selector__label {
display: flex;
align-items: center;
.icon {
height: 1rem;
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
}
@media only screen and (max-width: 1250px) {
column-count: 3;
}
@media only screen and (max-width: 1024px) {
column-count: 4;
}
@media only screen and (max-width: 960px) {
column-count: 3;
}
@media only screen and (max-width: 750px) {
column-count: 2;
}
@media only screen and (max-width: 530px) {
column-count: 1;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,338 @@
<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"
:accept="acceptFileFromProjectType(project.project_type)"
prompt="Upload a version"
class="brand-button iconified-button"
@change="handleFiles"
>
<UploadIcon />
</FileInput>
<span class="indicator">
<InfoIcon /> Click to choose a file or drag one onto this page
</span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<VersionFilterControl
:versions="props.versions"
@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 v-if="filteredVersions.length > 0" class="universal-card all-versions">
<div class="header">
<div />
<div>Version</div>
<div>Supports</div>
<div>Stats</div>
</div>
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="version-button button-transparent"
@click="
$router.push(
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
)
"
>
<a
v-tooltip="
version.primaryFile.filename + ' (' + $formatBytes(version.primaryFile.size) + ')'
"
:href="version.primaryFile.url"
class="download-button square-button brand-button"
:class="version.version_type"
:aria-label="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()"
>
<DownloadIcon aria-hidden="true" />
</a>
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
class="version__title"
>
{{ version.name }}
</nuxt-link>
<div class="version__metadata">
<VersionBadge v-if="version.version_type === 'release'" type="release" color="green" />
<VersionBadge v-else-if="version.version_type === 'beta'" type="beta" color="orange" />
<VersionBadge v-else-if="version.version_type === 'alpha'" type="alpha" color="red" />
<span class="divider" />
<span class="version_number">{{ version.version_number }}</span>
</div>
<div class="version__supports">
<span>
{{ version.loaders.map((x) => $formatCategory(x)).join(', ') }}
</span>
<span>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div class="version__stats">
<span>
<strong>{{ $formatNumber(version.downloads) }}</strong>
downloads
</span>
<span>
Published on
<strong>{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</strong>
</span>
</div>
</div>
</div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
</template>
<script setup>
import { acceptFileFromProjectType } from '~/helpers/fileUtils'
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'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return {}
},
},
})
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,
},
})
}
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',
},
state: {
newPrimaryFile: files[0],
},
})
}
</script>
<style lang="scss" scoped>
.header-buttons {
display: flex;
align-items: center;
gap: 1rem;
.indicator {
display: flex;
gap: 0.5ch;
align-items: center;
color: var(--color-text-inactive);
}
}
.all-versions {
display: flex;
flex-direction: column;
.header {
display: grid;
grid-template: 'download title supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
color: var(--color-text-dark);
font-size: var(--font-size-md);
font-weight: bold;
justify-content: left;
margin-inline: var(--spacing-card-md);
margin-bottom: var(--spacing-card-sm);
column-gap: var(--spacing-card-sm);
div:first-child {
grid-area: download;
}
div:nth-child(2) {
grid-area: title;
}
div:nth-child(3) {
grid-area: supports;
}
div:nth-child(4) {
grid-area: stats;
}
}
.version-button {
display: grid;
grid-template:
'download title supports stats'
'download metadata supports stats'
'download dummy supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
column-gap: var(--spacing-card-sm);
justify-content: left;
padding: var(--spacing-card-md);
.download-button {
grid-area: download;
}
.version__title {
grid-area: title;
font-weight: bold;
svg {
vertical-align: top;
}
}
.version__metadata {
grid-area: metadata;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--spacing-card-xs);
margin-top: var(--spacing-card-xs);
}
.version__supports {
grid-area: supports;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
.version__stats {
grid-area: stats;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
&:active:not(&:disabled) {
transform: scale(0.99) !important;
}
}
}
@media screen and (max-width: 1024px) {
.all-versions {
.header {
grid-template: 'download title';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
div:nth-child(3) {
display: none;
}
div:nth-child(4) {
display: none;
}
}
.version-button {
grid-template: 'download title' 'download metadata' 'download supports' 'download stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
row-gap: var(--spacing-card-xs);
.version__supports {
display: flex;
flex-direction: row;
flex-wrap: wrap;
column-gap: var(--spacing-card-xs);
}
.version__metadata {
margin: 0;
}
}
}
}
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
align-items: center;
flex-wrap: wrap;
.multiselect {
flex: 1;
}
.checkbox-outer {
min-width: fit-content;
}
}
</style>