diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b692bf74ae0a16386766a426585ee104343223..c3b18c5aab4d06de816644d647efd9d1471472c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Link settings that enable registrations and invites +- Ability to upload logo, background, default user avatar, instance thumbnail, and NSFW hiding images ### Changed diff --git a/src/api/mediaUpload.js b/src/api/mediaUpload.js new file mode 100644 index 0000000000000000000000000000000000000000..1cbde204c05a870aa7c6abae16c0144ee8cdb9ae --- /dev/null +++ b/src/api/mediaUpload.js @@ -0,0 +1,19 @@ +import { getToken } from '@/utils/auth' +import { baseName } from './utils' + +const UPLOAD_URL = '/api/v1/media' + +export function uploadMedia({ formData, authHost }) { + const url = baseName(authHost) + UPLOAD_URL + + return fetch(url, { + body: formData, + method: 'POST', + headers: authHeaders() + }) + .then((data) => data.json()) +} + +const authHeaders = () => { + return { 'Authorization': `Bearer ${getToken()}` } +} diff --git a/src/lang/en.js b/src/lang/en.js index 35d912528f4dec53cad1712a973c830d06d6215d..8375520900f4096836112ff7f3b509a87291cae9 100644 --- a/src/lang/en.js +++ b/src/lang/en.js @@ -391,7 +391,10 @@ export default { instanceReboot: 'Reboot Instance', restartApp: 'You must restart the instance to apply settings', restartSuccess: 'Instance rebooted successfully!', - removeSettingConfirmation: 'Are you sure you want to remove this setting\'s value from the database?' + removeSettingConfirmation: 'Are you sure you want to remove this setting\'s value from the database?', + changeImage: 'Change image', + uploadImage: 'Upload image', + remove: 'Remove' }, invites: { inviteTokens: 'Invite tokens', diff --git a/src/views/settings/components/Inputs.vue b/src/views/settings/components/Inputs.vue index 0b14e2828d481c4b2fcc9ebc0399f97399505ce0..d8ca8cc28cb44c371b9537e4bab0da6ce7103d28 100644 --- a/src/views/settings/components/Inputs.vue +++ b/src/views/settings/components/Inputs.vue @@ -33,8 +33,16 @@ </el-tooltip> </span> <div class="input-row"> + <image-upload-input + v-if="isImageUrl" + :data="data" + :setting-group="settingGroup" + :setting="setting" + :input-value="inputValue" + @change="update($event, settingGroup.group, settingGroup.key, settingParent, setting.key, setting.type, nested)" + /> <el-input - v-if="setting.type === 'string' || (setting.type.includes('string') && setting.type.includes('atom'))" + v-else-if="setting.type === 'string' || (setting.type.includes('string') && setting.type.includes('atom'))" :value="inputValue" :placeholder="setting.suggestions ? setting.suggestions[0] : null" :data-search="setting.key || setting.group" @@ -125,6 +133,7 @@ import { CrontabInput, EditableKeywordInput, IconsInput, + ImageUploadInput, MascotsInput, ProxyUrlInput, PruneInput, @@ -143,6 +152,7 @@ export default { CrontabInput, EditableKeywordInput, IconsInput, + ImageUploadInput, MascotsInput, ProxyUrlInput, PruneInput, @@ -276,6 +286,9 @@ export default { }, updatedSettings() { return this.$store.state.settings.updatedSettings + }, + isImageUrl() { + return [':background', ':logo', ':nsfwCensorImage', ':default_user_avatar', ':instance_thumbnail'].includes(this.setting.key) } }, methods: { diff --git a/src/views/settings/components/inputComponents/ImageUploadInput.vue b/src/views/settings/components/inputComponents/ImageUploadInput.vue new file mode 100644 index 0000000000000000000000000000000000000000..f54b4190f144c0ff088fcee263e3a7764792a817 --- /dev/null +++ b/src/views/settings/components/inputComponents/ImageUploadInput.vue @@ -0,0 +1,204 @@ +<template> + <div class="image-upload-area"> + <div class="input-row"> + <div :style="dimensions" class="image-upload-wrapper"> + <div :style="dimensions" class="image-upload-overlay"> + <input + :aria-label="$t('settings.changeImage')" + class="input-file" + type="file" + accept=".jpg,.jpeg,.png" + @change="handleFiles" > + <div class="caption"> + {{ $t('settings.changeImage') }} + </div> + <el-image + v-loading="loading" + :src="imageUrl(inputValue)" + :style="dimensions" + class="uploaded-image" + fit="cover" /> + </div> + </div> + </div> + <div class="image-button-group"> + <el-button class="upload-button" size="small"> + {{ $t('settings.uploadImage') }} + <input + :aria-label="$t('settings.changeImage')" + class="input-file" + type="file" + accept=".jpg,.jpeg,.png" + @change="handleFiles"> + </el-button> + <el-button v-if="!isDefault" type="danger" size="small" style="margin-left: 5px;" @click="removeFile()"> + {{ $t('settings.remove') }} + </el-button> + </div> + </div> +</template> + +<script> +import { mapGetters } from 'vuex' +import _ from 'lodash' +import { baseName } from '../../../../api/utils' +import { uploadMedia } from '../../../../api/mediaUpload' + +export default { + name: 'ImageUploadInput', + props: { + inputValue: { + type: [String, Object], + default: function() { + return {} + } + }, + setting: { + type: Object, + default: function() { + return {} + } + } + }, + data() { + return { + loading: false + } + }, + computed: { + ...mapGetters([ + 'authHost' + ]), + fullSize() { + if (_.includes([':background', ':nsfwCensorImage'], this.setting.key)) { + return true + } + + return false + }, + dimensions() { + return { + width: this.fullSize ? '100%' : '100px', + height: this.fullSize ? '250px' : '100px' + } + }, + isDefault() { + return this.defaultImage === this.inputValue + }, + defaultImage() { + return this.baseName + _.get(this.setting, 'suggestions[0]') + }, + baseName() { + return baseName(this.authHost) + } + }, + methods: { + imageUrl(url) { + if (_.isString(url)) { + const isUrl = url.startsWith('http') || url.startsWith('https') + return isUrl ? url : this.baseName + url + } else { + return this.defaultImage + } + }, + handleFiles(event) { + const file = event.target.files[0] + if (!file) { return } + const reader = new FileReader() + reader.onload = ({ target }) => { + const formData = new FormData() + formData.append('file', file) + this.loading = true + uploadMedia({ formData, authHost: this.authHost }).then(response => { + this.loading = false + this.$emit('change', response.url) + }) + } + reader.readAsDataURL(file) + }, + removeFile() { + this.$emit('change', this.defaultImage) + } + } +} +</script> + +<style rel='stylesheet/scss' lang='scss'> +@import '../../styles/main'; +@include settings; + +.image-upload-area { + .input-row { + display: flex; + align-items: center; + } + + .input-file { + z-index: 100; + position: absolute; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + } + + .image-button-group { + margin-top: 20px; + + .upload-button { + position: relative; + } + } + + .image-upload-wrapper { + position: relative; + + .image-upload-overlay { + transition: box-shadow .1s; + border-radius: 5px; + + .caption { + visibility: hidden; + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + font-weight: 700; + font-size: 10px; + text-transform: uppercase;; + color: #fff; + z-index: 9; + transition: box-shadow .1s; + } + + .uploaded-image { + border-radius: 5px; + box-shadow: 0 2px 10px 0 rgba(0,0,0,.1); + } + + &:hover { + visibility: visible; + cursor: pointer; + border-radius: 5px; + + .el-image__error { + visibility: hidden; + } + + .caption { + visibility: visible; + box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.1), inset 0 0 120px 25px rgba(0, 0, 0, 0.8); + border-radius: 5px; + } + } + } + } +} + +</style> diff --git a/src/views/settings/components/inputComponents/index.js b/src/views/settings/components/inputComponents/index.js index 3b9bc788e66afe54311cf7a3ef5dab2fd771cbd8..112819503463f491c232735915f551275845a8d0 100644 --- a/src/views/settings/components/inputComponents/index.js +++ b/src/views/settings/components/inputComponents/index.js @@ -2,6 +2,7 @@ export { default as AutoLinkerInput } from './AutoLinkerInput' export { default as EditableKeywordInput } from './EditableKeywordInput' export { default as CrontabInput } from './CrontabInput' export { default as IconsInput } from './IconsInput' +export { default as ImageUploadInput } from './ImageUploadInput' export { default as MascotsInput } from './MascotsInput' export { default as ProxyUrlInput } from './ProxyUrlInput' export { default as PruneInput } from './PruneInput'