Improve upload UI

- Remove a dependency
- Cleanup the code
- Improve the usability
- Allow to add a description to an image

The backend is unfortunately not yet working but that need some DB work
blocked by another pull requests

Signed-off-by: Carl Schwan <carl@carlschwan.eu>
pull/1444/head
Carl Schwan 2022-07-11 18:20:55 +02:00
rodzic 4ecb9e0142
commit 5a36e2474e
10 zmienionych plików z 295 dodań i 128 usunięć

Wyświetl plik

@ -106,6 +106,15 @@ class LocalController extends Controller {
$this->miscService = $miscService;
}
/**
* Upload file
*
* @NoAdminRequired
*/
public function uploadAttachement(): DataResponse {
}
/**
* Create a new post.

Wyświetl plik

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Controller;
use OCP\AppFramework\Controller;
use OCP\Files\IMimeTypeDetector;
class MediaApiController extends Controller {
public const IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/x-xbitmap',
'image/x-ms-bmp',
'image/bmp',
'image/svg+xml',
'image/webp',
];
private IMimeTypeDetector $mimeTypeDetector;
/**
* Creates an attachment to be used with a new status.
*
* @NoAdminRequired
*/
public function uploadMedia(): DataResponse {
// TODO
return DataResponse([
'id' => 1,
'url' => '',
'preview_url' => '',
'remote_url' => null,
'text_url' => '',
'description' => '',
]);
}
}

11
package-lock.json wygenerowano
Wyświetl plik

@ -27,7 +27,6 @@
"vue-click-outside": "^1.0.7",
"vue-contenteditable-directive": "^1.2.0",
"vue-infinite-loading": "^2.4.4",
"vue-masonry-css": "^1.0.3",
"vue-material-design-icons": "^5.0.0",
"vue-router": "^3.5.3",
"vue-tribute": "^1.0.6",
@ -27541,11 +27540,6 @@
}
}
},
"node_modules/vue-masonry-css": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/vue-masonry-css/-/vue-masonry-css-1.0.3.tgz",
"integrity": "sha512-viecHQiHVLez7HlYUQsv1wJb2MT/RDSzkDp6m3In41vPrk6OsBmT2qRE8LZqYIA4daIwrnx/Xm8h4fjOpuE3hw=="
},
"node_modules/vue-material-design-icons": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.0.0.tgz",
@ -49827,11 +49821,6 @@
"vue-style-loader": "^4.1.0"
}
},
"vue-masonry-css": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/vue-masonry-css/-/vue-masonry-css-1.0.3.tgz",
"integrity": "sha512-viecHQiHVLez7HlYUQsv1wJb2MT/RDSzkDp6m3In41vPrk6OsBmT2qRE8LZqYIA4daIwrnx/Xm8h4fjOpuE3hw=="
},
"vue-material-design-icons": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.0.0.tgz",

Wyświetl plik

@ -47,7 +47,6 @@
"vue-click-outside": "^1.0.7",
"vue-contenteditable-directive": "^1.2.0",
"vue-infinite-loading": "^2.4.4",
"vue-masonry-css": "^1.0.3",
"vue-material-design-icons": "^5.0.0",
"vue-router": "^3.5.3",
"vue-tribute": "^1.0.6",

Wyświetl plik

@ -1,5 +1,6 @@
<!--
- @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- @copyright Copyright (c) 2022 Carl Schwan <carl@carlschwan.eu>
-
- @author Julius Härtl <jus@bitgrid.net>
-
@ -24,12 +25,12 @@
<div class="new-post" data-id="">
<input id="file-upload"
ref="fileUploadInput"
@change="handleFileChange($event)"
multiple
type="file"
tabindex="-1"
aria-hidden="true"
class="hidden-visually"
@change="handleFileInput">
class="hidden-visually">
<div class="new-post-author">
<avatar :user="currentUser.uid" :display-name="currentUser.displayName" :disable-tooltip="true"
:size="32" />
@ -61,14 +62,7 @@
@tribute-replaced="updatePostFromTribute" />
</vue-tribute>
<masonry>
<div v-for="(item, index) in miniatures" :key="index" ref="miniatures">
<img alt="" :src="item.img" :usemap="'#map' + index">
<map :name="'map' + index">
<area shape="circle" :coords="getImageMapCoords(index)" @click="removeAttachment(index)">
</map>
</div>
</masonry>
<PreviewGrid :uploading="false" :uploadProgress="0.4" :miniatures="previewUrls" />
<div class="options">
<Button type="tertiary"
@ -82,7 +76,7 @@
<div class="new-post-form__emoji-picker">
<EmojiPicker ref="emojiPicker" :search="search" :close-on-select="false"
:container="containerElement"
:container="container"
@select="insert">
<Button type="tertiary"
:aria-haspopup="true"
@ -106,7 +100,7 @@
</div>
<div class="emptySpace" />
<Button :value="currentVisibilityPostLabel" :disabled="post.length < 1 || post==='<br>'" type="primary"
<Button :value="currentVisibilityPostLabel" :disabled="!canPost" type="primary"
@click.prevent="createPost">
<template #icon>
<Send title="" :size="22" decorative />
@ -129,11 +123,12 @@ import PopoverMenu from '@nextcloud/vue/dist/Components/PopoverMenu'
import EmojiPicker from '@nextcloud/vue/dist/Components/EmojiPicker'
import VueTribute from 'vue-tribute'
import he from 'he'
import CurrentUserMixin from './../mixins/currentUserMixin'
import FocusOnCreate from '../directives/focusOnCreate'
import CurrentUserMixin from '../../mixins/currentUserMixin'
import FocusOnCreate from '../../directives/focusOnCreate'
import axios from '@nextcloud/axios'
import ActorAvatar from './ActorAvatar.vue'
import ActorAvatar from '../ActorAvatar.vue'
import { generateUrl } from '@nextcloud/router'
import PreviewGrid from './PreviewGrid'
export default {
name: 'Composer',
@ -147,6 +142,7 @@ export default {
EmoticonOutline,
Button,
Send,
PreviewGrid,
},
directives: {
FocusOnCreate,
@ -160,6 +156,7 @@ export default {
post: '',
miniatures: [], // miniatures of images stored in postAttachments
postAttachments: [], // The toot's attachments
previewUrls: [],
canType: true,
search: '',
replyTo: null,
@ -268,9 +265,6 @@ export default {
return t('social', 'Post to mentioned users')
}
},
containerElement() {
return document.querySelector('#content-vue')
},
currentVisibilityIconClass() {
return this.visibilityIconClass(this.type)
},
@ -366,6 +360,12 @@ export default {
containerElement() {
return document.querySelector(this.container)
},
canPost() {
if (this.previewUrls.length > 0) {
return true;
}
return this.post.length !== 0 && this.post !== '<br>'
}
},
mounted() {
this.$root.$on('composer-reply', (data) => {
@ -377,98 +377,16 @@ export default {
clickImportInput() {
this.$refs.fileUploadInput.click()
},
handleFileInput() {
// TODO: handle (or prevent) mulitples/ files
let self = this
let file = this.$refs.fileUploadInput.files[0]
let reader = new FileReader()
// Called when selected file is completly loaded to draw a miniature
reader.onload = function(e) {
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
let width = 265
let height = 180
let img = new Image()
// Called when img.src is set below
img.onload = function() {
// scale image for miniature
let imgWidth = this.width
let imgHeight = this.height
imgHeight = Math.floor(imgHeight * (width / imgWidth))
imgWidth = width
if (imgHeight > height) {
imgWidth = Math.floor(imgWidth * (height / imgHeight))
imgHeight = height
}
canvas.width = imgWidth
canvas.height = imgHeight
ctx.drawImage(this, 0, 0, imgWidth, imgHeight)
// Draw a border
ctx.beginPath()
ctx.fillStyle = 'black'
ctx.lineWidth = 1
ctx.moveTo(0, 0)
ctx.lineTo(imgWidth, 0)
ctx.lineTo(imgWidth, imgHeight)
ctx.lineTo(0, imgHeight)
ctx.lineTo(0, 0)
ctx.stroke()
// Create a close badge in the upper-right corner
ctx.beginPath()
ctx.arc(imgWidth - 20, 20, 10, 0, 2 * Math.PI)
ctx.fillStyle = 'white'
ctx.fill()
ctx.lineWidth = 2
ctx.StrokeStyle = 'darkgray'
ctx.stroke()
ctx.beginPath()
ctx.moveTo(imgWidth - (20 + 5), 20 - 5)
ctx.lineTo(imgWidth - (20 - 5), 20 + 5)
ctx.stroke()
ctx.moveTo(imgWidth - (20 - 5), 20 - 5)
ctx.lineTo(imgWidth - (20 + 5), 20 + 5)
ctx.stroke()
// Add filename to generic icon for non image document
if (!e.target.result.startsWith('data:image')) {
ctx.fillStyle = 'black'
ctx.font = '12px Arial'
ctx.fillText(file.name, 30, imgHeight - 20)
}
// Save miniature
self.miniatures.push({
'img': canvas.toDataURL(),
'coords': String(imgWidth - 20) + ',20,10'
})
}
// Save document
self.postAttachments.push(e.target.result)
// Draw a generic icon when document is not an image
if (e.target.result.startsWith('data:image')) {
img.src = e.target.result
} else {
img.src = generateUrl('svg/core/filetypes/x-office-document?color=d8d8d8')
}
}
// Start reading selected file
reader.readAsDataURL(file)
handleFileChange(event) {
const previewUrl = URL.createObjectURL(event.target.files[0])
this.previewUrls.push({
description: '',
url: previewUrl,
result: event.target.files[0],
})
},
removeAttachment(idx) {
this.postAttachments.splice(idx, 1)
this.miniatures.splice(idx, 1)
},
getImageMapCoords(idx) {
return this.miniatures[idx].coords
this.previewUrls.splice(idx, 1)
},
insert(emoji) {
if (typeof emoji === 'object') {
@ -537,7 +455,7 @@ export default {
to: to,
hashtags: hashtags,
type: this.type,
attachments: this.postAttachments
attachments: this.previewUrls.map(preview => preview.result), // TODO send the summary and other props too
}
if (this.replyTo) {
@ -587,8 +505,7 @@ export default {
this.replyTo = null
this.post = ''
this.$refs.composerInput.innerText = this.post
this.postAttachments = []
this.miniatures = []
this.previewUrls = []
this.$store.dispatch('refreshTimeline')
})
@ -648,7 +565,7 @@ export default {
}
.reply-to {
background-image: url(../../img/reply.svg);
background-image: url(../../../img/reply.svg);
background-position: 5px 5px;
background-repeat: no-repeat;
margin-left: 39px;

Wyświetl plik

@ -0,0 +1,72 @@
<!--
SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="upload-form">
<div class="upload-progress" v-if="false">
<div class="upload-progress__icon">
<FileUpload :size="32" />
</div>
<div class="upload-progress__message">
{{ t('social', 'Uploading...') }}
<div class="upload-progress__backdrop">
<div class="upload-progress__tracker" :style="`width: ${uploadProgress * 100}%`" />
</div>
</div>
</div>
<div class="preview-grid">
<PreviewGridItem v-for="(item, index) in miniatures" :key="index" :preview="item" :index="index" @delete="deletePreview" />
</div>
</div>
</template>
<script>
import PreviewGridItem from './PreviewGridItem'
import FileUpload from 'vue-material-design-icons/FileUpload'
export default {
name: 'PreviewGrid',
components: {
PreviewGridItem,
FileUpload,
},
props: {
uploadProgress: {
type: Number,
required: true,
},
uploading: {
type: Boolean,
required: true,
},
miniatures: {
type: Array,
required: true,
},
},
methods: {
deletePreview(index) {
console.debug("rjeoijreo")
this.miniatures.splice(index, 1)
}
},
}
</script>
<style scoped lang="scss">
.upload-progress {
display: flex;
}
.preview-grid {
display: flex;
flex-wrap: wrap;
flex-direction: row;
margin-left: -5px;
margin-right: -5px;
}
</style>

Wyświetl plik

@ -0,0 +1,139 @@
<template>
<div class="preview-item-wrapper">
<div class="preview-item" :style="backgroundStyle">
<div class="preview-item__actions">
<Button type="tertiary-no-background" @click="$emit('delete', index)">
<template #icon>
<Close :size="16" fillColor="white" />
</template>
<span>{{ t('social', 'Delete') }}</span>
</Button>
<Button type="tertiary-no-background" @click="showModal">
<template #icon>
<Edit :size="16" fillColor="white" />
</template>
<span>{{ t('social', 'Edit') }}</span>
</Button>
</div>
<div class="description-warning" v-if="preview.description.length === 0">
{{ t('social', 'No description added') }}
</div>
<Modal v-if="modal" @close="closeModal" size="small">
<div class="modal__content">
<label :for="`image-description-${index}`">
{{ t('social', 'Describe for the visually impaired') }}
</label>
<textarea :id="`image-description-${index}`" v-model="preview.description">
</textarea>
<Button type="primary" @click="closeModal">{{ t('social', 'Close') }}</Button>
</div>
</Modal>
</div>
</div>
</template>
<script>
import Close from 'vue-material-design-icons/Close'
import Edit from 'vue-material-design-icons/Pencil'
import Button from '@nextcloud/vue/dist/Components/Button'
import Modal from '@nextcloud/vue/dist/Components/Modal'
export default {
name: 'PreviewGridItem',
components: {
Close,
Edit,
Button,
Modal,
},
data() {
return {
modal: false,
}
},
methods: {
showModal() {
this.modal = true
},
closeModal() {
this.modal = false
}
},
props: {
preview: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
backgroundStyle() {
return {
backgroundImage: `url("${this.preview.url}")`,
}
},
},
}
</script>
<style scoped lang="scss">
.preview-item-wrapper {
flex: 1 1 0;
min-width: 40%;
margin: 5px;
}
.preview-item {
border-radius: 4px;
background-color: #000;
background-position: 50%;
background-size: cover;
background-repeat: no-repeat;
height: 140px;
width: 100%;
overflow: hidden;
position: relative;
.button-vue--vue-tertiary-no-background {
color: white !important;
}
&__actions {
background: linear-gradient(180deg,rgba(0,0,0,.8),rgba(0,0,0,.35) 80%,transparent);
display: flex;
align-items: flex-start;
justify-content: space-between;
.button-vue__text {
color: white !important;
}
}
.description-warning {
position: absolute;
z-index: 2;
bottom: 0;
left: 0;
right: 0;
box-sizing: border-box;
background: linear-gradient(0deg,rgba(0,0,0,.8),rgba(0,0,0,.35) 80%,transparent);
color: white;
padding: 10px;
}
}
.modal__content {
padding: 20px;
}
textarea {
width: 100%;
height: 100px;
margin-bottom: 20px;
}
</style>

Wyświetl plik

@ -30,7 +30,6 @@ import vuetwemoji from 'vue-twemoji'
import contenteditableDirective from 'vue-contenteditable-directive'
import ClickOutside from 'vue-click-outside'
import VTooltip from '@nextcloud/vue/dist/Directives/Tooltip'
import VueMasonry from 'vue-masonry-css'
sync(store, router)
@ -57,7 +56,6 @@ Vue.use(vuetwemoji, {
className: 'emoji', // custom className for image output
size: 'twemoji' // image size
})
Vue.use(VueMasonry)
/* eslint-disable-next-line no-new */
new Vue({

Wyświetl plik

@ -92,7 +92,7 @@
</style>
<script>
import Composer from './../components/Composer.vue'
import Composer from './../components/Composer/Composer.vue'
import CurrentUserMixin from './../mixins/currentUserMixin'
import follow from './../mixins/follow'
import TimelineList from './../components/TimelineList.vue'

Wyświetl plik

@ -21,7 +21,7 @@
</style>
<script>
import Composer from '../components/Composer.vue'
import Composer from '../components/Composer/Composer.vue'
import ProfileInfo from '../components/ProfileInfo.vue'
import TimelineEntry from '../components/TimelineEntry.vue'
import TimelineList from '../components/TimelineList.vue'