kopia lustrzana https://github.com/nextcloud/social
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
rodzic
4ecb9e0142
commit
5a36e2474e
|
@ -106,6 +106,15 @@ class LocalController extends Controller {
|
|||
$this->miscService = $miscService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file
|
||||
*
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
public function uploadAttachement(): DataResponse {
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new post.
|
||||
|
|
|
@ -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' => '',
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
|
@ -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>
|
|
@ -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({
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
Ładowanie…
Reference in New Issue