Makes mod editing only send changed data (#286)

* Add getDifferences lib

* Only send mod diff to backend for changes

* Disable fields when lacking permissions
This commit is contained in:
venashial
2021-10-02 16:47:59 -07:00
committed by GitHub
parent f4636fdca2
commit c518f373df
6 changed files with 207 additions and 70 deletions

View File

@@ -364,7 +364,7 @@
} }
} }
} }
.tab:last-child { .tab:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -404,6 +404,12 @@
&:active { &:active {
background-color: var(--color-button-bg-active); background-color: var(--color-button-bg-active);
} }
&:disabled,
&[disabled] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
} }
.transparent-button { .transparent-button {

View File

@@ -234,7 +234,7 @@ textarea {
border: 2px solid transparent; border: 2px solid transparent;
&:focus, &:focus,
&:hover { &:hover:not([disabled]) {
background: var(--color-button-bg-hover); background: var(--color-button-bg-hover);
color: var(--color-text); color: var(--color-text);
outline: none; outline: none;
@@ -252,6 +252,13 @@ textarea {
&::placeholder { &::placeholder {
color: var(--color-color-text); color: var(--color-color-text);
} }
&:disabled,
&[disabled] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
} }
.ea-content { .ea-content {

23
libs/getDifferences.js Normal file
View File

@@ -0,0 +1,23 @@
const equalArrays = (arr1, arr2) =>
arr1.length === arr2.length &&
arr1.every((element, index) => element === arr2[index])
const isObject = (obj) => obj instanceof Object && !Array.isArray(obj)
const isArray = (arr) => Array.isArray(arr)
export default function getDifferences(obj1, obj2) {
const obj3 = {}
for (const key of Object.keys(obj1)) {
const val1 = obj1[key]
const val2 = obj2[key]
const areArrays = isArray(val1) && isArray(val2)
const areObjects = isObject(val1) && isObject(val2)
if (areObjects) {
const diff = getDifferences(val1, val2)
if (diff) obj3[key] = diff
} else if (areArrays && !equalArrays(val1, val2)) obj3[key] = val2
else if (val1 !== val2) obj3[key] = val2
}
return !!Object.keys(obj3).length && obj3
}

1
package-lock.json generated
View File

@@ -5,7 +5,6 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "knossos",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.12.5", "@nuxtjs/axios": "^5.12.5",

View File

@@ -16,7 +16,7 @@
" "
title="Submit for approval" title="Submit for approval"
class="button column" class="button column"
:disabled="!this.$nuxt.$loading" :disabled="!$nuxt.$loading"
@click="saveModReview" @click="saveModReview"
> >
Submit for approval Submit for approval
@@ -24,7 +24,7 @@
<button <button
title="Save" title="Save"
class="brand-button column" class="brand-button column"
:disabled="!this.$nuxt.$loading" :disabled="!$nuxt.$loading"
@click="saveMod" @click="saveMod"
> >
Save Save
@@ -36,7 +36,14 @@
<span> <span>
Be creative. TechCraft v7 won't be searchable and won't be clicked on. Be creative. TechCraft v7 won't be searchable and won't be clicked on.
</span> </span>
<input v-model="mod.title" type="text" placeholder="Enter the name" /> <input
v-model="mod.title"
type="text"
placeholder="Enter the name"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label> </label>
<h3>Summary</h3> <h3>Summary</h3>
<label> <label>
@@ -47,6 +54,9 @@
v-model="mod.description" v-model="mod.description"
type="text" type="text"
placeholder="Enter the summary" placeholder="Enter the summary"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</label> </label>
<h3>Categories</h3> <h3>Categories</h3>
@@ -69,6 +79,9 @@
:limit="6" :limit="6"
:hide-selected="true" :hide-selected="true"
placeholder="Choose categories" placeholder="Choose categories"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</label> </label>
<h3>Vanity URL (slug)</h3> <h3>Vanity URL (slug)</h3>
@@ -81,17 +94,23 @@
v-model="mod.slug" v-model="mod.slug"
type="text" type="text"
placeholder="Enter the vanity URL's last bit" placeholder="Enter the vanity URL's last bit"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</label> </label>
</section> </section>
<section class="mod-icon rows"> <section class="mod-icon rows">
<h3>Icon</h3> <h3>Icon</h3>
<div class="columns row-grow-1"> <div class="columns row-grow-1">
<div class="column-grow-1 rows"> <div class="rows row-grow-1">
<file-input <file-input
accept="image/png,image/jpeg,image/gif,image/webp" accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image" class="choose-image"
prompt="Choose an image or drag it here" prompt="Choose an image or drag it here"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
@change="showPreviewImage" @change="showPreviewImage"
/> />
<ul class="row-grow-1"> <ul class="row-grow-1">
@@ -99,8 +118,23 @@
<li>Minimum size is 100x100</li> <li>Minimum size is 100x100</li>
<li>Acceptable formats are PNG, JPEG, GIF, and WEBP</li> <li>Acceptable formats are PNG, JPEG, GIF, and WEBP</li>
</ul> </ul>
</div>
<div class="rows">
<img
:src="
previewImage
? previewImage
: mod.icon_url && !iconChanged
? mod.icon_url
: 'https://cdn.modrinth.com/placeholder.svg'
"
alt="preview-image"
/>
<button <button
class="transparent-button" class="button"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
@click=" @click="
icon = null icon = null
previewImage = null previewImage = null
@@ -110,16 +144,6 @@
Reset icon Reset icon
</button> </button>
</div> </div>
<img
:src="
previewImage
? previewImage
: mod.icon_url && !iconChanged
? mod.icon_url
: 'https://cdn.modrinth.com/placeholder.svg'
"
alt="preview-image"
/>
</div> </div>
</section> </section>
<section class="game-sides"> <section class="game-sides">
@@ -133,25 +157,31 @@
<div class="labeled-control"> <div class="labeled-control">
<h3>Client</h3> <h3>Client</h3>
<Multiselect <Multiselect
v-model="clientSideType" v-model="mod.client_side"
placeholder="Select one" placeholder="Select one"
:options="sideTypes" :options="sideTypes"
:searchable="false" :searchable="false"
:close-on-select="true" :close-on-select="true"
:show-labels="false" :show-labels="false"
:allow-empty="false" :allow-empty="false"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</div> </div>
<div class="labeled-control"> <div class="labeled-control">
<h3>Server</h3> <h3>Server</h3>
<Multiselect <Multiselect
v-model="serverSideType" v-model="mod.server_side"
placeholder="Select one" placeholder="Select one"
:options="sideTypes" :options="sideTypes"
:searchable="false" :searchable="false"
:close-on-select="true" :close-on-select="true"
:show-labels="false" :show-labels="false"
:allow-empty="false" :allow-empty="false"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</div> </div>
</div> </div>
@@ -177,7 +207,11 @@
</span> </span>
<div class="columns"> <div class="columns">
<div class="textarea-wrapper"> <div class="textarea-wrapper">
<textarea id="body" v-model="mod.body"></textarea> <textarea
id="body"
v-model="mod.body"
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
></textarea>
</div> </div>
<div v-compiled-markdown="mod.body" class="markdown-body"></div> <div v-compiled-markdown="mod.body" class="markdown-body"></div>
</div> </div>
@@ -194,6 +228,9 @@
v-model="mod.issues_url" v-model="mod.issues_url"
type="url" type="url"
placeholder="Enter a valid URL" placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</label> </label>
<label title="A page/repository containing the source code for your mod."> <label title="A page/repository containing the source code for your mod.">
@@ -202,6 +239,9 @@
v-model="mod.source_url" v-model="mod.source_url"
type="url" type="url"
placeholder="Enter a valid URL" placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</label> </label>
<label <label
@@ -212,6 +252,9 @@
v-model="mod.wiki_url" v-model="mod.wiki_url"
type="url" type="url"
placeholder="Enter a valid URL" placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</label> </label>
<label title="An invitation link to your Discord server."> <label title="An invitation link to your Discord server.">
@@ -220,6 +263,9 @@
v-model="mod.discord_url" v-model="mod.discord_url"
type="url" type="url"
placeholder="Enter a valid URL" placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
</label> </label>
</section> </section>
@@ -252,8 +298,18 @@
:searchable="true" :searchable="true"
:close-on-select="true" :close-on-select="true"
:show-labels="false" :show-labels="false"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
<input
v-model="license_url"
type="url"
placeholder="License URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/> />
<input v-model="license_url" type="url" placeholder="License URL" />
</div> </div>
</label> </label>
</section> </section>
@@ -314,6 +370,8 @@
<script> <script>
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
import getDifferences from '~/libs/getDifferences'
import FileInput from '~/components/ui/FileInput' import FileInput from '~/components/ui/FileInput'
export default { export default {
@@ -321,10 +379,27 @@ export default {
FileInput, FileInput,
Multiselect, Multiselect,
}, },
beforeRouteLeave(to, from, next) {
if (
this.isEditing &&
!window.confirm('Are you sure that you want to leave without saving?')
) {
return
}
next()
},
props: {
currentMember: {
type: Object,
default() {
return null
},
},
},
async asyncData(data) { async asyncData(data) {
try { try {
const [ const [
mod, savedMod,
availableCategories, availableCategories,
availableLoaders, availableLoaders,
availableGameVersions, availableGameVersions,
@@ -341,21 +416,22 @@ export default {
]) ])
).map((it) => it.data) ).map((it) => it.data)
mod.license = { savedMod.license = {
short: mod.license.id, short: savedMod.license.id,
name: mod.license.name, name: savedMod.license.name,
url: mod.license.url, url: savedMod.license.url,
} }
if (mod.body_url && !mod.body) { if (savedMod.body_url && !savedMod.body) {
mod.body = (await data.$axios.get(mod.body_url)).data savedMod.body = (await data.$axios.get(savedMod.body_url)).data
} }
/*
const donationPlatforms = [] const donationPlatforms = []
const donationLinks = [] const donationLinks = []
if (mod.donation_urls) { if (savedMod.donation_urls) {
for (const platform of mod.donation_urls) { for (const platform of savedMod.donation_urls) {
donationPlatforms.push({ donationPlatforms.push({
short: platform.id, short: platform.id,
name: platform.platform, name: platform.platform,
@@ -363,24 +439,24 @@ export default {
donationLinks.push(platform.url) donationLinks.push(platform.url)
} }
} }
*/
availableLicenses.sort((a, b) => a.name.localeCompare(b.name)) availableLicenses.sort((a, b) => a.name.localeCompare(b.name))
return { return {
mod, savedMod,
clientSideType: mod.client_side.charAt(0) + mod.client_side.slice(1), mod: { ...savedMod },
serverSideType: mod.server_side.charAt(0) + mod.server_side.slice(1),
availableCategories, availableCategories,
availableLoaders, availableLoaders,
availableGameVersions, availableGameVersions,
availableLicenses, availableLicenses,
license: { license: {
short: mod.license.id, short: savedMod.license.id,
name: mod.license.name, name: savedMod.license.name,
}, },
license_url: mod.license.url, license_url: savedMod.license.url,
availableDonationPlatforms, availableDonationPlatforms,
donationPlatforms, // donationPlatforms,
donationLinks, // donationLinks,
} }
} catch { } catch {
data.error({ data.error({
@@ -398,7 +474,7 @@ export default {
icon: null, icon: null,
iconChanged: false, iconChanged: false,
sideTypes: ['Required', 'Optional', 'Unsupported'], sideTypes: ['required', 'optional', 'unsupported'],
isEditing: true, isEditing: true,
} }
@@ -429,17 +505,17 @@ export default {
window.removeEventListener('beforeunload', preventLeave) window.removeEventListener('beforeunload', preventLeave)
}) })
}, },
beforeRouteLeave(to, from, next) {
if (
this.isEditing &&
!window.confirm('Are you sure that you want to leave without saving?')
) {
return
}
next()
},
created() { created() {
this.$emit('update:link-bar', [['Edit', 'edit']]) this.$emit('update:link-bar', [['Edit', 'edit']])
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_MOD = 1 << 7
}, },
methods: { methods: {
async saveModReview() { async saveModReview() {
@@ -449,22 +525,27 @@ export default {
async saveMod() { async saveMod() {
this.$nuxt.$loading.start() this.$nuxt.$loading.start()
const modChanges = getDifferences(this.savedMod, this.mod)
try { try {
const data = { const data = {
title: this.mod.title, ...({ title: modChanges.title } || {}),
description: this.mod.description, ...({ description: modChanges.description } || {}),
body: this.mod.body, ...({ body: modChanges.body } || {}),
categories: this.mod.categories, ...({ categories: modChanges.categories } || {}),
issues_url: this.mod.issues_url, ...({ issues_url: modChanges.issues_url } || {}),
source_url: this.mod.source_url, ...({ source_url: modChanges.source_url } || {}),
wiki_url: this.mod.wiki_url, ...({ wiki_url: modChanges.wiki_url } || {}),
license_url: this.license_url, ...({ license_url: modChanges.license_url } || {}),
discord_url: this.mod.discord_url, ...({ discord_url: modChanges.discord_url } || {}),
license_id: this.license.short, ...({ license_id: modChanges.license_id } || {}),
client_side: this.clientSideType.toLowerCase(), ...({ client_side: modChanges.client_side } || {}),
server_side: this.serverSideType.toLowerCase(), ...({ server_side: modChanges.server_side } || {}),
slug: this.mod.slug, ...({ slug: modChanges.slug } || {}),
license: this.license.short, ...(modChanges.license
? { license: modChanges.license.short } || {}
: {}),
/*
donation_urls: this.donationPlatforms.map((it, index) => { donation_urls: this.donationPlatforms.map((it, index) => {
return { return {
id: it.short, id: it.short,
@@ -472,6 +553,7 @@ export default {
url: this.donationLinks[index], url: this.donationLinks[index],
} }
}), }),
*/
} }
if (this.isProcessing) { if (this.isProcessing) {
@@ -491,9 +573,13 @@ export default {
} }
this.isEditing = false this.isEditing = false
await this.$router.replace( this.savedMod = this.mod
`/mod/${this.mod.slug ? this.mod.slug : this.mod.id}`
) this.$notify({
group: 'main',
title: 'Changes saved',
type: 'success',
})
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
@@ -624,8 +710,9 @@ section.mod-icon {
img { img {
align-self: flex-start; align-self: flex-start;
max-width: 50%; max-width: 6.08rem;
margin-left: var(--spacing-card-lg); margin-left: var(--spacing-card-lg);
border-radius: var(--size-rounded-icon);
} }
} }

View File

@@ -23,7 +23,14 @@
<span> <span>
This leads to a page where you can create a version for your mod. This leads to a page where you can create a version for your mod.
</span> </span>
<nuxt-link class="button" to="newversion">Create version</nuxt-link> <nuxt-link
class="button"
to="newversion"
:disabled="
(currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
"
>Create version</nuxt-link
>
</label> </label>
<h3>Delete mod</h3> <h3>Delete mod</h3>
<label> <label>
@@ -44,7 +51,10 @@
</section> </section>
<div class="section-header columns team-invite"> <div class="section-header columns team-invite">
<h3 class="column-grow-1">Team members</h3> <h3 class="column-grow-1">Team members</h3>
<div class="column"> <div
v-if="(currentMember.permissions & MANAGE_INVITES) === MANAGE_INVITES"
class="column"
>
<input <input
id="username" id="username"
v-model="currentUsername" v-model="currentUsername"
@@ -355,7 +365,12 @@ export default {
this.$nuxt.$loading.finish() this.$nuxt.$loading.finish()
}, },
showPopup() { showPopup() {
this.$refs.delete_popup.show() if (
(this.currentMember.permissions & this.DELETE_MOD) ===
this.DELETE_MOD
) {
this.$refs.delete_popup.show()
}
}, },
async deleteMod() { async deleteMod() {
await this.$axios.delete(`mod/${this.mod.id}`, this.$auth.headers) await this.$axios.delete(`mod/${this.mod.id}`, this.$auth.headers)