Modrinth/pages/mod/create.vue
2020-11-30 15:10:30 -07:00

952 lines
24 KiB
Vue

<template>
<div class="page-container">
<div class="page-contents">
<header class="columns">
<h2 class="column-grow-1">Create a mod</h2>
<button
title="Save draft"
class="button column"
:disabled="!this.$nuxt.$loading"
@click="createDraft"
>
Save draft
</button>
<button
title="Create"
class="brand-button column"
:disabled="!this.$nuxt.$loading"
@click="createMod"
>
Create
</button>
</header>
<EthicalAd class="advert" />
<section class="essentials">
<h3>Name</h3>
<label>
<span>
Be creative. TechCraft v7 won't be searchable and won't be clicked
on
</span>
<input v-model="name" type="text" placeholder="Enter the name" />
</label>
<h3>Summary</h3>
<label>
<span>
Give a quick description to your mod. It will appear in the search
</span>
<input
v-model="description"
type="text"
placeholder="Enter the summary"
/>
</label>
<h3>Categories</h3>
<label>
<span>
Select up to 3 categories. They will help to find your mod
</span>
<multiselect
id="categories"
v-model="categories"
:options="availableCategories"
:loading="availableCategories.length === 0"
:multiple="true"
:searchable="false"
:show-no-results="false"
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:max="3"
:limit="6"
:hide-selected="true"
placeholder="Choose categories"
/>
</label>
<h3>Vanity URL (slug)</h3>
<label>
<span>
Set this to something pretty, so URLs to your mod are more readable
</span>
<input
id="name"
v-model="slug"
type="text"
placeholder="Enter the vanity URL's last bit"
/>
</label>
</section>
<section class="mod-icon rows">
<h3>Icon</h3>
<div class="columns row-grow-1">
<div class="column-grow-1 rows">
<file-input
accept="image/png,image/jpeg,image/gif"
class="choose-image"
prompt="Choose image or drag it here"
@change="showPreviewImage"
/>
<ul class="row-grow-1">
<li>Must be a square</li>
<li>Minimum size is 100x100</li>
<li>Acceptable formats are PNG, JPEG and GIF</li>
</ul>
<button
class="transparent-button"
@click="
icon = null
previewImage = null
"
>
Reset icon
</button>
</div>
<img
:src="
previewImage
? previewImage
: 'https://cdn.modrinth.com/placeholder.svg'
"
alt="preview-image"
/>
</div>
</section>
<section class="game-sides">
<h3>Supported environments</h3>
<div class="columns">
<span>
Let others know if your mod is for clients, servers or universal.
For example, IC2 will be required + required, while OptiFine will be
required + no functionality
</span>
<div class="labeled-control">
<h3>Client</h3>
<Multiselect
v-model="clientSideType"
placeholder="Select one"
track-by="id"
label="label"
:options="sideTypes"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
</div>
<div class="labeled-control">
<h3>Server</h3>
<Multiselect
v-model="serverSideType"
placeholder="Select one"
track-by="id"
label="label"
:options="sideTypes"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
</div>
</div>
</section>
<section class="description">
<h3>
<label
for="body"
title="You can type the of the long form of your description here."
>
Description
</label>
</h3>
<span>
You can type the of the long form of your description here. This
editor supports markdown. You can find the syntax
<a
href="https://guides.github.com/features/mastering-markdown/"
target="_blank"
rel="noopener noreferrer"
>here</a
>.
</span>
<div class="columns">
<div class="textarea-wrapper">
<textarea id="body" v-model="body"></textarea>
</div>
<div v-compiled-markdown="body" class="markdown-body"></div>
</div>
</section>
<section class="versions">
<div class="title">
<h3>Upload Versions</h3>
<button
title="Add a version"
class="button"
:disabled="currentVersionIndex !== -1"
@click="createVersion"
>
Add a version
</button>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Mod Loader</th>
<th>Minecraft Version</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="(version, index) in versions.filter((it) =>
currentVersionIndex !== -1
? it !== versions[currentVersionIndex]
: true
)"
:key="version.id"
>
<td>
{{ version.name }}
</td>
<td>{{ version.version_number }}</td>
<td>
<FabricIcon v-if="version.loaders.includes('fabric')" />
<ForgeIcon v-if="version.loaders.includes('forge')" />
</td>
<td>{{ version.game_versions.join(', ') }}</td>
<td>
<span
v-if="version.version_type === 'release'"
class="badge green"
>
Release
</span>
<span
v-if="version.version_type === 'beta'"
class="badge yellow"
>
Beta
</span>
<span v-if="version.version_type === 'alpha'" class="badge red">
Alpha
</span>
</td>
<td>
<button
title="Remove version"
@click="versions.splice(index, 1)"
>
Remove
</button>
<button
title="Edit version"
@click="currentVersionIndex = index"
>
Edit
</button>
</td>
</tr>
</tbody>
</table>
<hr v-if="currentVersionIndex !== -1" />
<div v-if="currentVersionIndex !== -1" class="new-version">
<div class="controls">
<button
class="brand-button"
title="Save version"
@click="currentVersionIndex = -1"
>
Save version
</button>
<button title="Discard version" @click="deleteVersion">
Discard
</button>
</div>
<div class="main">
<h3>Name</h3>
<label>
<span>
This is what users will see first. Will default to version
number
</span>
<input
v-model="versions[currentVersionIndex].name"
type="text"
placeholder="Enter the name"
/>
</label>
<h3>Number</h3>
<label>
<span>
That's how your version will appear in mod lists and in URLs
</span>
<input
v-model="versions[currentVersionIndex].version_number"
type="text"
placeholder="Enter the number"
/>
</label>
<h3>Channel</h3>
<label>
<span>
It is important to notify players and pack makers if the version
is stable
</span>
<multiselect
v-model="versions[currentVersionIndex].release_channel"
placeholder="Select one"
:options="['release', 'beta', 'alpha']"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
</label>
<h3>Loaders</h3>
<label>
<span>
Mark all loaders this version works with. It is essential for
search
</span>
<multiselect
v-model="versions[currentVersionIndex].loaders"
:options="availableLoaders"
:loading="availableLoaders.length === 0"
:multiple="true"
:searchable="false"
:show-no-results="false"
:close-on-select="true"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose loaders..."
/>
</label>
<h3>Game versions</h3>
<label>
<span>
Mark all game version this version supports. It is essential for
search
</span>
<multiselect
v-model="versions[currentVersionIndex].game_versions"
:options="availableGameVersions"
:loading="availableGameVersions.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose versions..."
/>
</label>
<h3>Files</h3>
<label>
<span>
You should upload a single JAR file. However, you are allowed to
upload multiple
</span>
<FileInput
accept="application/*"
multiple
prompt="Choose files or drag them here"
@change="updateVersionFiles"
/>
</label>
</div>
<div class="changelog">
<h3>Changelog</h3>
<span>
Tell players and modpack makers what's new. It supports the same
markdown as description, but it is advisable not to be too
creative with it in changelogs
</span>
<div class="textarea-wrapper">
<textarea
v-model="versions[currentVersionIndex].changelog"
></textarea>
</div>
</div>
</div>
</section>
<section class="extra-links">
<div class="title">
<h3>External links</h3>
<i>— this section is optional</i>
</div>
<label
title="A place for users to report bugs, issues, and concerns about your mod."
>
<span>Issue tracker</span>
<input
v-model="issues_url"
type="url"
placeholder="Enter a valid URL"
/>
</label>
<label title="A page/repository containing the source code">
<span>Source code</span>
<input
v-model="source_url"
type="url"
placeholder="Enter a valid URL"
/>
</label>
<label
title="A page containing information, documentation, and help for the mod."
>
<span>Wiki page</span>
<input
v-model="wiki_url"
type="url"
placeholder="Enter a valid URL"
/>
</label>
<label title="An inivitation link to your Discord server.">
<span>Discord invite</span>
<input
v-model="wiki_url"
type="url"
placeholder="Enter a valid URL"
/>
</label>
</section>
<section class="license">
<div class="title">
<h3>License</h3>
<i>— this section is optional</i>
</div>
<label>
<span>
It is really important to choose a proper license for your mod. You
may choose one from our list or provide a URL to your own license.
URL field will be filled automatically for provided licenses
</span>
<div class="input-group">
<Multiselect
v-model="license"
placeholder="Select one"
track-by="short"
label="name"
:options="availableLicenses"
:searchable="true"
:close-on-select="true"
:show-labels="false"
/>
<input v-model="license_url" type="url" placeholder="License URL" />
</div>
</label>
</section>
<!--
<section class="donations">
<div class="title">
<h3>Donation links</h3>
<i> this section is optional</i>
</div>
</section>
-->
<m-footer class="footer" centered />
</div>
</div>
</template>
<script>
import axios from 'axios'
import Multiselect from 'vue-multiselect'
import MFooter from '@/components/MFooter'
import FileInput from '@/components/FileInput'
import EthicalAd from '@/components/EthicalAd'
import ForgeIcon from '~/assets/images/categories/forge.svg?inline'
import FabricIcon from '~/assets/images/categories/fabric.svg?inline'
export default {
components: {
MFooter,
FileInput,
EthicalAd,
Multiselect,
ForgeIcon,
FabricIcon,
},
async asyncData() {
const [
availableCategories,
availableLoaders,
availableGameVersions,
availableLicenses,
// availableDonationPlatforms,
] = (
await Promise.all([
axios.get(`https://api.modrinth.com/api/v1/tag/category`),
axios.get(`https://api.modrinth.com/api/v1/tag/loader`),
axios.get(`https://api.modrinth.com/api/v1/tag/game_version`),
axios.get(`https://api.modrinth.com/api/v1/tag/license`),
// axios.get(`https://api.modrinth.com/api/v1/tag/donation_platform`),
])
).map((it) => it.data)
return {
availableCategories,
availableLoaders,
availableGameVersions,
availableLicenses,
// availableDonationPlatforms,
}
},
data() {
return {
previewImage: null,
compiledBody: '',
releaseChannels: ['beta', 'alpha', 'release'],
currentVersionIndex: -1,
name: '',
slug: '',
draft: false,
description: '',
body: '',
versions: [],
categories: [],
issues_url: null,
source_url: null,
wiki_url: null,
icon: null,
license: null,
license_url: null,
sideTypes: [
{ label: 'Required', id: 'required' },
{ label: 'No functionality', id: 'no-functionality' },
{ label: 'Unsupported', id: 'unsupported' },
],
clientSideType: { label: 'Required', id: 'required' },
serverSideType: { label: 'Required', id: 'required' },
}
},
watch: {
license(newValue, oldValue) {
if (newValue == null) {
this.license_url = ''
return
}
switch (newValue.short) {
case 'custom':
this.license_url = ''
break
default:
this.license_url = `https://cdn.modrinth.com/licenses/${newValue.short}.txt`
}
},
},
methods: {
async createDraft() {
this.draft = true
await this.createMod()
},
async createMod() {
this.$nuxt.$loading.start()
const formData = new FormData()
formData.append(
'data',
JSON.stringify({
mod_name: this.name,
mod_slug: this.slug,
mod_namespace: this.namespace,
mod_description: this.description,
mod_body: this.body,
initial_versions: this.versions,
team_members: [
{
user_id: this.$auth.user.id,
name: this.$auth.user.username,
role: 'Owner',
},
],
categories: this.categories,
issues_url: this.issues_url,
source_url: this.source_url,
wiki_url: this.wiki_url,
client_side: this.clientSideType.id,
server_side: this.serverSideType.id,
license: this.license,
license_url: this.license_url,
is_draft: this.draft,
})
)
if (this.icon) {
formData.append('icon', new Blob([this.icon]), this.icon.name)
}
for (const version of this.versions) {
for (let i = 0; i < version.raw_files.length; i++) {
formData.append(
version.file_parts[i],
new Blob([version.raw_files[i]]),
version.raw_files[i].name
)
}
}
try {
await axios({
url: 'https://api.modrinth.com/api/v1/mod',
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
Authorization: this.$auth.getToken('local'),
},
})
await this.$router.replace('/dashboard/projects')
} catch (err) {
this.$notify({
group: 'main',
title: 'An Error Occurred',
text: err.response.data.description,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
this.$nuxt.$loading.finish()
},
showPreviewImage(e) {
const reader = new FileReader()
this.icon = e.target.files[0]
reader.readAsDataURL(this.icon)
reader.onload = (event) => {
this.previewImage = event.target.result
}
},
updateVersionFiles(e) {
this.versions[this.currentVersionIndex].raw_files = e.target.files
const newFileParts = []
for (let i = 0; i < e.target.files.length; i++) {
newFileParts.push(e.target.files[i].name.concat('-' + i))
}
this.versions[this.currentVersionIndex].file_parts = newFileParts
},
createVersion() {
this.versions.push({
raw_files: [],
file_parts: [],
version_number: '',
version_title: '',
version_body: '',
dependencies: [],
game_versions: [],
release_channel: 'release',
loaders: [],
featured: false,
})
this.currentVersionIndex = this.versions.length - 1
},
deleteVersion() {
this.versions.splice(this.currentVersionIndex, 1)
this.currentVersionIndex = -1
},
},
}
</script>
<style lang="scss" scoped>
.title {
* {
display: inline;
}
}
label {
display: flex;
span {
flex: 2;
padding-right: var(--spacing-card-lg);
}
input,
.multiselect,
.input-group {
flex: 3;
height: fit-content;
}
}
.input-group {
display: flex;
flex-direction: column;
* {
margin-bottom: var(--spacing-card-sm);
}
}
.textarea-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
textarea {
flex: 1;
overflow-y: auto;
resize: none;
max-width: 100%;
}
}
.page-contents {
display: grid;
grid-template:
'header header header' auto
'advert advert advert' auto
'essentials essentials mod-icon' auto
'game-sides game-sides game-sides' auto
'description description description' auto
'versions versions versions' auto
'extra-links license license' auto
'donations donations .' auto
'footer footer footer' auto
/ 4fr 1fr 4fr;
column-gap: var(--spacing-card-md);
row-gap: var(--spacing-card-md);
}
header {
@extend %card;
grid-area: header;
padding: var(--spacing-card-md) var(--spacing-card-lg);
h2 {
margin: auto 0;
color: var(--color-text-dark);
font-weight: var(--font-weight-extrabold);
}
button {
margin-left: 0.5rem;
}
}
.advert {
grid-area: advert;
}
section {
@extend %card;
padding: var(--spacing-card-md) var(--spacing-card-lg);
}
section.essentials {
grid-area: essentials;
}
section.mod-icon {
grid-area: mod-icon;
img {
align-self: flex-start;
max-width: 50%;
margin-left: var(--spacing-card-lg);
}
}
section.game-sides {
grid-area: game-sides;
.columns {
flex-wrap: wrap;
span {
flex: 2;
}
.labeled-control {
flex: 2;
margin-left: var(--spacing-card-lg);
}
}
}
section.description {
grid-area: description;
& > .columns {
align-items: stretch;
min-height: 10rem;
max-height: 40rem;
& > * {
flex: 1;
max-width: 50%;
}
}
.markdown-body {
overflow-y: auto;
padding: 0 var(--spacing-card-sm);
}
}
section.versions {
grid-area: versions;
table {
border-collapse: collapse;
margin-bottom: var(--spacing-card-md);
background: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
table-layout: fixed;
width: 100%;
* {
text-align: left;
}
tr:not(:last-child),
tr:first-child {
th,
td {
border-bottom: 1px solid var(--color-divider);
}
}
th,
td {
&:first-child {
text-align: center;
width: 7%;
svg {
color: var(--color-text);
&:hover,
&:focus {
color: var(--color-text-hover);
}
}
}
&:nth-child(2),
&:nth-child(5) {
padding-left: 0;
width: 12%;
}
}
th {
color: var(--color-heading);
font-size: 0.8rem;
letter-spacing: 0.02rem;
margin-bottom: 0.5rem;
margin-top: 1.5rem;
padding: 0.75rem 1rem;
text-transform: uppercase;
}
td {
overflow: hidden;
padding: 0.75rem 1rem;
img {
height: 3rem;
width: 3rem;
}
}
}
hr {
background-color: var(--color-divider);
border: none;
color: var(--color-divider);
height: 2px;
margin: 0.5rem 0;
}
.new-version {
display: grid;
grid-template:
'controls controls' auto
'main changelog' auto
/ 5fr 4fr;
column-gap: var(--spacing-card-md);
.controls {
grid-area: controls;
display: flex;
flex-direction: row-reverse;
}
.main {
grid-area: main;
}
.changelog {
grid-area: changelog;
display: flex;
flex-direction: column;
.textarea-wrapper {
flex: 1;
}
}
}
}
section.extra-links {
grid-area: extra-links;
label {
align-items: center;
margin-top: var(--spacing-card-sm);
span {
flex: 1;
}
}
}
section.license {
grid-area: license;
label {
margin-top: var(--spacing-card-sm);
}
}
section.donations {
grid-area: donations;
}
.footer {
grid-area: footer;
}
.choose-image {
cursor: pointer;
}
</style>