You've already forked AstralRinth
forked from didirus/AstralRinth
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:
1323
pages/[type]/[id].vue
Normal file
1323
pages/[type]/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
235
pages/[type]/[id]/changelog.vue
Normal file
235
pages/[type]/[id]/changelog.vue
Normal 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>
|
||||
816
pages/[type]/[id]/gallery.vue
Normal file
816
pages/[type]/[id]/gallery.vue
Normal 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>
|
||||
21
pages/[type]/[id]/index.vue
Normal file
21
pages/[type]/[id]/index.vue
Normal 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>
|
||||
136
pages/[type]/[id]/settings/description.vue
Normal file
136
pages/[type]/[id]/settings/description.vue
Normal 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>
|
||||
397
pages/[type]/[id]/settings/index.vue
Normal file
397
pages/[type]/[id]/settings/index.vue
Normal 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>
|
||||
302
pages/[type]/[id]/settings/license.vue
Normal file
302
pages/[type]/[id]/settings/license.vue
Normal 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>
|
||||
274
pages/[type]/[id]/settings/links.vue
Normal file
274
pages/[type]/[id]/settings/links.vue
Normal 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>
|
||||
472
pages/[type]/[id]/settings/members.vue
Normal file
472
pages/[type]/[id]/settings/members.vue
Normal 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>
|
||||
294
pages/[type]/[id]/settings/tags.vue
Normal file
294
pages/[type]/[id]/settings/tags.vue
Normal 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>
|
||||
1581
pages/[type]/[id]/version/[version].vue
Normal file
1581
pages/[type]/[id]/version/[version].vue
Normal file
File diff suppressed because it is too large
Load Diff
8
pages/[type]/[id]/version/[version]/edit.vue
Normal file
8
pages/[type]/[id]/version/[version]/edit.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
338
pages/[type]/[id]/versions.vue
Normal file
338
pages/[type]/[id]/versions.vue
Normal 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>
|
||||
Reference in New Issue
Block a user