Merge branch 'proper-attachments' into shigusegubu

* proper-attachments:
  better attachments in uploading (grid layout)
  better handling of unknown files, better upload display
  better compact attachments
  merged in compact notifs and improved upon it
  gallery in post status form!
  gallery now supports flash, fixes for flash component. refactored media modal
  refactored attachments and gallery. All attachments now are in gallery.
  Use proper setting name
  Use cleaner instance config check for shoutbox setting
  Make locale language cleaner
  Don't shorten shoutbox to SB
  Fix lint error
  Update CHANGELOG.md
  New option: Hide shoutbox
  compact notifs
This commit is contained in:
Henry Jameson 2021-06-18 17:33:54 +03:00
commit 08ce9ba30b
20 changed files with 640 additions and 534 deletions

View file

@ -11,7 +11,11 @@ import {
faImage, faImage,
faVideo, faVideo,
faPlayCircle, faPlayCircle,
faTimes faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -20,27 +24,35 @@ library.add(
faImage, faImage,
faVideo, faVideo,
faPlayCircle, faPlayCircle,
faTimes faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt
) )
const Attachment = { const Attachment = {
props: [ props: [
'attachment', 'attachment',
'description',
'hideDescription',
'nsfw', 'nsfw',
'size', 'size',
'allowPlay',
'setMedia', 'setMedia',
'naturalSizeLoad' 'remove',
'edit'
], ],
data () { data () {
return { return {
localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw, hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage, preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false, loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false, modalOpen: false,
showHidden: false showHidden: false,
flashLoaded: false
} }
}, },
components: { components: {
@ -49,9 +61,24 @@ const Attachment = {
VideoAttachment VideoAttachment
}, },
computed: { computed: {
classNames () {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined
},
'-type-' + this.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`
]
},
usePlaceholder () { usePlaceholder () {
return this.size === 'hide' || this.type === 'unknown' return this.size === 'hide' || this.type === 'unknown'
}, },
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
},
placeholderName () { placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) { if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase() return this.type.toUpperCase()
@ -76,22 +103,28 @@ const Attachment = {
isEmpty () { isEmpty () {
return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown' return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
}, },
isSmall () {
return this.size === 'small'
},
fullwidth () {
if (this.size === 'hide') return false
return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
},
useModal () { useModal () {
const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio'] let modalTypes = []
: this.mergedConfig.playVideosInModal switch (this.size) {
? ['image', 'video'] case 'hide':
: ['image'] case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
return modalTypes.includes(this.type) return modalTypes.includes(this.type)
}, },
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
watch: {
localDescription (newVal) {
this.onEdit(newVal)
}
},
methods: { methods: {
linkClicked ({ target }) { linkClicked ({ target }) {
if (target.tagName === 'A') { if (target.tagName === 'A') {
@ -100,12 +133,27 @@ const Attachment = {
}, },
openModal (event) { openModal (event) {
if (this.useModal) { if (this.useModal) {
event.stopPropagation() this.$emit('setMedia')
event.preventDefault() this.$store.dispatch('setCurrentMedia', this.attachment)
this.setMedia()
this.$store.dispatch('setCurrent', this.attachment)
} }
}, },
openModalForce (event) {
this.$emit('setMedia')
this.$store.dispatch('setCurrentMedia', this.attachment)
},
onEdit (event) {
console.log('ONEDIT', event)
this.edit && this.edit(this.attachment, event)
},
onRemove () {
this.remove && this.remove(this.attachment)
},
stopFlash () {
this.$refs.flash.closePlayer()
},
setFlashLoaded (event) {
this.flashLoaded = event
},
toggleHidden (event) { toggleHidden (event) {
if ( if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) && (this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
@ -132,7 +180,7 @@ const Attachment = {
onImageLoad (image) { onImageLoad (image) {
const width = image.naturalWidth const width = image.naturalWidth
const height = image.naturalHeight const height = image.naturalHeight
this.naturalSizeLoad && this.naturalSizeLoad({ width, height }) this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
} }
} }
} }

View file

@ -1,7 +1,8 @@
<template> <template>
<div <button
v-if="usePlaceholder" v-if="usePlaceholder"
:class="{ 'fullwidth': fullwidth }" class="Attachment -placeholder button-unstyled"
:class="classNames"
@click="openModal" @click="openModal"
> >
<a <a
@ -11,318 +12,212 @@
:href="attachment.url" :href="attachment.url"
:alt="attachment.description" :alt="attachment.description"
:title="attachment.description" :title="attachment.description"
@click.prevent=""
> >
<FAIcon :icon="placeholderIconClass" /> <FAIcon :icon="placeholderIconClass" />
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ this.edit ? '' : placeholderName }}
</a> </a>
</div>
<div
v-else
v-show="!isEmpty"
class="attachment"
:class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
>
<a
v-if="hidden"
class="image-attachment"
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent.stop="toggleHidden"
>
<img
:key="nsfwImage"
class="nsfw"
:src="nsfwImage"
:class="{'small': isSmall}"
>
<FAIcon
v-if="type === 'video'"
class="play-icon"
icon="play-circle"
/>
</a>
<button
v-if="nsfw && hideNsfwLocal && !hidden"
class="button-unstyled hider"
@click.prevent="toggleHidden"
>
<FAIcon icon="times" />
</button>
<a
v-if="type === 'image' && (!hidden || preloadImage)"
class="image-attachment"
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
@click="openModal"
>
<StillImage
class="image"
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
:alt="attachment.description"
/>
</a>
<a
v-if="type === 'video' && !hidden"
class="video-container"
:class="{'small': isSmall}"
:href="allowPlay ? undefined : attachment.url"
@click="openModal"
>
<VideoAttachment
class="video"
:attachment="attachment"
:controls="allowPlay"
@play="$emit('play')"
@pause="$emit('pause')"
/>
<FAIcon
v-if="!allowPlay"
class="play-icon"
icon="play-circle"
/>
</a>
<audio
v-if="type === 'audio'"
:src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
controls
@play="$emit('play')"
@pause="$emit('pause')"
/>
<div <div
v-if="type === 'html' && attachment.oembed" class="attachment-buttons"
class="oembed" v-if="edit || remove"
@click.prevent="linkClicked"
>
<div
v-if="attachment.thumb_url"
class="image"
> >
<img :src="attachment.thumb_url"> <button
</div> v-if="remove"
<div class="text"> class="button-unstyled attachment-button"
<!-- eslint-disable vue/no-v-html --> @click.prevent="onRemove"
<h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> >
<div v-html="attachment.oembed.oembedHTML" /> <FAIcon icon="trash-alt" />
<!-- eslint-enable vue/no-v-html --> </button>
</div>
</div> </div>
<div
v-if="size !== 'hide' && !hideDescription && (edit || localDescription)"
class="description-container"
:class="{ '-static': !edit }"
>
<input
v-if="edit"
v-model="localDescription"
type="text"
class="description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>
</div>
</button>
<div
class="Attachment"
:class="classNames"
v-else
>
<div
class="attachment-wrapper"
v-show="!isEmpty"
>
<a
v-if="hidden"
class="image-container"
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent.stop="toggleHidden"
>
<img
:key="nsfwImage"
class="nsfw"
:src="nsfwImage"
>
<FAIcon
v-if="type === 'video'"
class="play-icon"
icon="play-circle"
/>
</a>
<div
class="attachment-buttons"
v-if="!hidden"
>
<button
v-if="type === 'flash' && flashLoaded"
class="button-unstyled attachment-button"
@click.prevent="stopFlash"
>
<FAIcon icon="stop" />
</button>
<button
v-if="!useModal"
class="button-unstyled attachment-button"
@click.prevent="openModalForce"
>
<FAIcon icon="search-plus" />
</button>
<button
v-if="nsfw && hideNsfwLocal"
class="button-unstyled attachment-button"
@click.prevent="toggleHidden"
>
<FAIcon icon="times" />
</button>
<button
v-if="remove"
class="button-unstyled attachment-button"
@click.prevent="onRemove"
>
<FAIcon icon="trash-alt" />
</button>
</div>
<Flash <a
v-if="type === 'flash'" v-if="type === 'image' && (!hidden || preloadImage)"
:src="attachment.large_thumb_url || attachment.url" class="image-container"
/> :class="{'-hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
@click.stop.prevent="openModal"
>
<StillImage
class="image"
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
:alt="attachment.description"
/>
</a>
<a
v-if="type === 'video' && !hidden"
class="video-container"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<VideoAttachment
class="video"
:attachment="attachment"
:controls="!useModal"
@play="$emit('play')"
@pause="$emit('pause')"
/>
<FAIcon
v-if="useModal"
class="play-icon"
icon="play-circle"
/>
</a>
<a
v-if="type === 'audio' && !hidden"
class="audio-container"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<audio
v-if="type === 'audio'"
:src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
controls
@play="$emit('play')"
@pause="$emit('pause')"
/>
</a>
<div
v-if="type === 'html' && attachment.oembed"
class="oembed-container"
@click.prevent="linkClicked"
>
<div
v-if="attachment.thumb_url"
class="image"
>
<img :src="attachment.thumb_url">
</div>
<div class="text">
<!-- eslint-disable vue/no-v-html -->
<h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
<div v-html="attachment.oembed.oembedHTML" />
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
<a
v-if="type === 'flash' && !hidden"
class="flash-container"
:href="attachment.url"
@click.stop.prevent="openModal"
>
<Flash
class="flash"
ref="flash"
:src="attachment.large_thumb_url || attachment.url"
@playerOpened="setFlashLoaded(true)"
@playerClosed="setFlashLoaded(false)"
/>
</a>
</div>
<div
v-if="size !== 'hide' && !hideDescription && (edit || localDescription)"
class="description-container"
:class="{ '-static': !edit }"
>
<input
v-if="edit"
v-model="localDescription"
type="text"
class="description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
<p v-else>
{{ localDescription }}
</p>
</div>
</div> </div>
</template> </template>
<script src="./attachment.js"></script> <script src="./attachment.js"></script>
<style lang="scss"> <style src="./attachment.scss" lang="scss"></style>
@import '../../_variables.scss';
.attachments {
display: flex;
flex-wrap: wrap;
.non-gallery {
max-width: 100%;
}
.placeholder {
display: inline-block;
padding: 0.3em 1em 0.3em 0;
color: $fallback--link;
color: var(--postLink, $fallback--link);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
svg {
color: inherit;
}
}
.nsfw-placeholder {
cursor: pointer;
&.loading {
cursor: progress;
}
}
.attachment {
position: relative;
margin-top: 0.5em;
align-self: flex-start;
line-height: 0;
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
overflow: hidden;
}
.non-gallery.attachment {
&.flash,
&.video {
flex: 1 0 40%;
}
.nsfw {
height: 260px;
}
.small {
height: 120px;
flex-grow: 0;
}
.video {
height: 260px;
display: flex;
}
video {
max-height: 100%;
object-fit: contain;
}
}
.fullwidth {
flex-basis: 100%;
}
// fixes small gap below video
&.video {
line-height: 0;
}
.video-container {
display: flex;
max-height: 100%;
}
.video {
width: 100%;
height: 100%;
}
.play-icon {
position: absolute;
font-size: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
}
.play-icon::before {
margin: 0;
}
&.html {
flex-basis: 90%;
width: 100%;
display: flex;
}
.hider {
position: absolute;
right: 0;
margin: 10px;
padding: 0;
z-index: 4;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
text-align: center;
width: 2em;
height: 2em;
font-size: 1.25em;
// TODO: theming? hard to theme with unknown background image color
background: rgba(230, 230, 230, 0.7);
.svg-inline--fa {
color: rgba(0, 0, 0, 0.6);
}
&:hover .svg-inline--fa {
color: rgba(0, 0, 0, 0.9);
}
}
video {
z-index: 0;
}
audio {
width: 100%;
}
img.media-upload {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
.oembed {
line-height: 1.2em;
flex: 1 0 100%;
width: 100%;
margin-right: 15px;
display: flex;
img {
width: 100%;
}
.image {
flex: 1;
img {
border: 0px;
border-radius: 5px;
height: 100%;
object-fit: cover;
}
}
.text {
flex: 2;
margin: 8px;
word-break: break-all;
h1 {
font-size: 14px;
margin: 0px;
}
}
}
.image-attachment {
&,
& .image {
width: 100%;
height: 100%;
}
&.hidden {
display: none;
}
.nsfw {
object-fit: cover;
width: 100%;
height: 100%;
}
img {
image-orientation: from-image; // NOTE: only FF supports this
}
}
}
</style>

View file

@ -62,10 +62,6 @@
&.with-media { &.with-media {
width: 100%; width: 100%;
.gallery-row {
overflow: hidden;
}
.status { .status {
width: 100%; width: 100%;
} }

View file

@ -39,12 +39,13 @@ const Flash = {
this.player = 'error' this.player = 'error'
}) })
this.ruffleInstance = player this.ruffleInstance = player
this.$emit('playerOpened')
}) })
}, },
closePlayer () { closePlayer () {
console.log(this.ruffleInstance) this.ruffleInstance && this.ruffleInstance.remove()
this.ruffleInstance.remove()
this.player = false this.player = false
this.$emit('playerClosed')
} }
} }
} }

View file

@ -36,13 +36,6 @@
</p> </p>
</span> </span>
</button> </button>
<button
v-if="player"
class="button-unstyled hider"
@click="closePlayer"
>
<FAIcon icon="stop" />
</button>
</div> </div>
</template> </template>
@ -51,8 +44,9 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.Flash { .Flash {
display: inline-block;
width: 100%; width: 100%;
height: 260px; height: 100%;
position: relative; position: relative;
.player { .player {
@ -60,6 +54,16 @@
width: 100%; width: 100%;
} }
.placeholder {
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--link);
}
.hider { .hider {
top: 0; top: 0;
} }
@ -76,13 +80,5 @@
display: none; display: none;
visibility: 'hidden'; visibility: 'hidden';
} }
.placeholder {
height: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
} }
</style> </style>

View file

@ -1,15 +1,24 @@
import Attachment from '../attachment/attachment.vue' import Attachment from '../attachment/attachment.vue'
import { chunk, last, dropRight, sumBy } from 'lodash' import { sumBy } from 'lodash'
const Gallery = { const Gallery = {
props: [ props: [
'attachments', 'attachments',
'limitRows',
'descriptions',
'limit',
'nsfw', 'nsfw',
'setMedia' 'setMedia',
'size',
'editable',
'removeAttachment',
'editAttachment',
'grid'
], ],
data () { data () {
return { return {
sizes: {} sizes: {},
hidingLong: true
} }
}, },
components: { Attachment }, components: { Attachment },
@ -18,26 +27,70 @@ const Gallery = {
if (!this.attachments) { if (!this.attachments) {
return [] return []
} }
const rows = chunk(this.attachments, 3) const attachments = this.limit > 0
if (last(rows).length === 1 && rows.length > 1) { ? this.attachments.slice(0, this.limit)
// if 1 attachment on last row -> add it to the previous row instead : this.attachments
const lastAttachment = last(rows)[0] if (this.size === 'hide') {
const allButLastRow = dropRight(rows) return attachments.map(item => ({ minimal: true, items: [item] }))
last(allButLastRow).push(lastAttachment)
return allButLastRow
} }
const rows = this.grid
? [{ grid: true, items: attachments }]
: attachments.reduce((acc, attachment, i) => {
if (attachment.mimetype.includes('audio')) {
return [...acc, { audio: true, items: [attachment] }, { items: [] }]
}
if (!(
attachment.mimetype.includes('image') ||
attachment.mimetype.includes('video') ||
attachment.mimetype.includes('flash')
)) {
return [...acc, { minimal: true, items: [attachment] }, { items: [] }]
}
const maxPerRow = 3
const attachmentsRemaining = this.attachments.length - i + 1
const currentRow = acc[acc.length - 1].items
currentRow.push(attachment)
if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) {
return [...acc, { items: [] }]
} else {
return acc
}
}, [{ items: [] }]).filter(_ => _.items.length > 0)
return rows return rows
}, },
useContainFit () { attachmentsDimensionalScore () {
return this.$store.getters.mergedConfig.useContainFit return this.rows.reduce((acc, row) => {
let size = 0
if (row.minimal) {
size += 1 / 8
} else if (row.audio) {
size += 1 / 4
} else {
size += 1 / (row.items.length + 0.6)
}
return acc + size
}, 0)
},
tooManyAttachments () {
if (this.editable || this.size === 'small') {
return false
} else if (this.size === 'hide') {
return this.attachments.length > 8
} else {
return this.attachmentsDimensionalScore > 1
}
} }
}, },
methods: { methods: {
onNaturalSizeLoad (id, size) { onNaturalSizeLoad ({ id, width, height }) {
this.$set(this.sizes, id, size) this.$set(this.sizes, id, { width, height })
}, },
rowStyle (itemsPerRow) { rowStyle (row) {
return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` } if (row.audio) {
return { 'padding-bottom': '25%' } // fixed reduced height for audio
} else if (!row.minimal && !row.grid) {
return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
}
}, },
itemStyle (id, row) { itemStyle (id, row) {
const total = sumBy(row, item => this.getAspectRatio(item.id)) const total = sumBy(row, item => this.getAspectRatio(item.id))
@ -46,6 +99,16 @@ const Gallery = {
getAspectRatio (id) { getAspectRatio (id) {
const size = this.sizes[id] const size = this.sizes[id]
return size ? size.width / size.height : 1 return size ? size.width / size.height : 1
},
toggleHidingLong (event) {
this.hidingLong = event
},
openGallery () {
this.$store.dispatch('setMedia', this.attachments)
this.$store.dispatch('setCurrentMedia', this.attachments[0])
},
onMedia () {
this.$store.dispatch('setMedia', this.attachments)
} }
} }
} }

View file

@ -1,26 +1,82 @@
<template> <template>
<div <div
class="Gallery"
ref="galleryContainer" ref="galleryContainer"
style="width: 100%;" :class="{ '-long': tooManyAttachments && hidingLong }"
> >
<div class="gallery-rows">
<div
v-for="(row, index) in rows"
:key="index"
class="gallery-row"
:style="rowStyle(row)"
:class="{ '-audio': row.audio, '-minimal': row.minimal, '-grid': grid }"
>
<div
class="gallery-row-inner"
:class="{ '-grid': grid }"
>
<attachment
v-for="attachment in row.items"
class="gallery-item"
:key="attachment.id"
:nsfw="nsfw"
:attachment="attachment"
:allow-play="false"
:size="size"
:editable="editable"
:remove="removeAttachment"
:edit="editAttachment"
:description="descriptions && descriptions[attachment.id]"
:hideDescription="size === 'small' || tooManyAttachments && hidingLong"
:style="itemStyle(attachment.id, row.items)"
@setMedia="onMedia"
@naturalSizeLoad="onNaturalSizeLoad"
/>
</div>
</div>
</div>
<div <div
v-for="(row, index) in rows" v-if="tooManyAttachments"
:key="index" class="many-attachments"
class="gallery-row"
:style="rowStyle(row.length)"
:class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
> >
<div class="gallery-row-inner"> <div class="many-attachments-text">
<attachment {{ $t("status.many_attachments", { number: attachments.length })}}
v-for="attachment in row" </div>
:key="attachment.id" <div class="many-attachments-buttons">
:set-media="setMedia" <span
:nsfw="nsfw" v-if="!hidingLong"
:attachment="attachment" class="many-attachments-button"
:allow-play="false" >
:natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)" <button
:style="itemStyle(attachment.id, row)" class="button-unstyled -link"
/> @click="toggleHidingLong(true)"
>
{{ $t("status.collapse_attachments") }}
</button>
</span>
<span
v-if="hidingLong"
class="many-attachments-button"
>
<button
class="button-unstyled -link"
@click="toggleHidingLong(false)"
>
{{ $t("status.show_all_attachments") }}
</button>
</span>
<span
class="many-attachments-button"
v-if="hidingLong"
>
<button
class="button-unstyled -link"
@click="openGallery"
>
{{ $t("status.open_gallery") }}
</button>
</span>
</div> </div>
</div> </div>
</div> </div>
@ -31,12 +87,66 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.gallery-row { .Gallery {
position: relative; .gallery-rows {
height: 0; display: flex;
width: 100%; flex-direction: column;
flex-grow: 1; }
margin-top: 0.5em;
.gallery-row {
position: relative;
height: 0;
width: 100%;
flex-grow: 1;
&:not(:first-child) {
margin-top: 0.5em;
}
}
&.-long {
.gallery-rows {
max-height: 25em;
overflow: hidden;
mask:
linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
}
.many-attachments-text {
text-align: center;
line-height: 2;
}
.many-attachments-buttons {
display: flex;
}
.many-attachments-button {
display: flex;
flex: 1;
justify-content: center;
line-height: 2;
button {
padding: 0 2em;
}
}
.gallery-row {
&.-grid,
&.-minimal {
height: auto;
.gallery-row-inner {
position: relative;
}
}
}
.gallery-row-inner { .gallery-row-inner {
position: absolute; position: absolute;
@ -48,9 +158,24 @@
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-content: stretch; align-content: stretch;
&.-grid {
width: 100%;
height: auto;
position: relative;
display: grid;
grid-column-gap: 0.5em;
grid-row-gap: 0.5em;
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
.gallery-item {
margin: 0;
height: 200px;
}
}
} }
.gallery-row-inner .attachment { .gallery-item {
margin: 0 0.5em 0 0; margin: 0 0.5em 0 0;
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;
@ -61,32 +186,5 @@
margin: 0; margin: 0;
} }
} }
.image-attachment {
width: 100%;
height: 100%;
}
.video-container {
height: 100%;
}
&.contain-fit {
img,
video,
canvas {
object-fit: contain;
height: 100%;
}
}
&.cover-fit {
img,
video,
canvas {
object-fit: cover;
}
}
} }
</style> </style>

View file

@ -3,6 +3,7 @@ import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue' import Modal from '../modal/modal.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
import Flash from 'src/components/flash/flash.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faChevronLeft, faChevronLeft,
@ -18,7 +19,8 @@ const MediaModal = {
components: { components: {
StillImage, StillImage,
VideoAttachment, VideoAttachment,
Modal Modal,
Flash
}, },
computed: { computed: {
showing () { showing () {
@ -67,13 +69,13 @@ const MediaModal = {
goPrev () { goPrev () {
if (this.canNavigate) { if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
this.$store.dispatch('setCurrent', this.media[prevIndex]) this.$store.dispatch('setCurrentMedia', this.media[prevIndex])
} }
}, },
goNext () { goNext () {
if (this.canNavigate) { if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
this.$store.dispatch('setCurrent', this.media[nextIndex]) this.$store.dispatch('setCurrentMedia', this.media[nextIndex])
} }
}, },
handleKeyupEvent (e) { handleKeyupEvent (e) {

View file

@ -28,6 +28,13 @@
:title="currentMedia.description" :title="currentMedia.description"
controls controls
/> />
<Flash
v-if="type === 'flash'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
/>
<button <button
v-if="canNavigate" v-if="canNavigate"
:title="$t('media_modal.previous')" :title="$t('media_modal.previous')"

View file

@ -182,8 +182,9 @@
</router-link> </router-link>
</div> </div>
<template v-else> <template v-else>
<status-content <StatusContent
class="faint" class="faint"
:compact="true"
:status="notification.action" :status="notification.action"
/> />
</template> </template>

View file

@ -4,6 +4,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue' import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue' import Attachment from '../attachment/attachment.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -85,7 +86,8 @@ const PostStatusForm = {
Checkbox, Checkbox,
Select, Select,
Attachment, Attachment,
StatusContent StatusContent,
Gallery
}, },
mounted () { mounted () {
this.updateIdempotencyKey() this.updateIdempotencyKey()
@ -388,6 +390,10 @@ const PostStatusForm = {
this.newStatus.files.splice(index, 1) this.newStatus.files.splice(index, 1)
this.$emit('resize') this.$emit('resize')
}, },
editAttachment (fileInfo, newText) {
console.log(fileInfo, newText)
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
uploadFailed (errString, templateArgs) { uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {} templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)

View file

@ -287,32 +287,20 @@
@click="clearError" @click="clearError"
/> />
</div> </div>
<div class="attachments"> <gallery
<div v-if="newStatus.files && newStatus.files.length > 0"
v-for="file in newStatus.files" class="attachments"
:key="file.url" :grid="true"
class="media-upload-wrapper" :nsfw="false"
> :attachments="newStatus.files"
<button :descriptions="newStatus.mediaDescriptions"
class="button-unstyled hider" :set-media="() => $store.dispatch('setMedia', newStatus.files)"
@click="removeMediaFile(file)" :editable="true"
> :editAttachment="editAttachment"
<FAIcon icon="times" /> :removeAttachment="removeMediaFile"
</button> @play="$emit('mediaplay', attachment.id)"
<attachment @pause="$emit('mediapause', attachment.id)"
:attachment="file" />
:set-media="() => $store.dispatch('setMedia', newStatus.files)"
size="small"
allow-play="false"
/>
<input
v-model="newStatus.mediaDescriptions[file.id]"
type="text"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
>
</div>
</div>
<div <div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings" class="upload_settings"
@ -330,26 +318,13 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.tribute-container {
ul {
padding: 0px;
li {
display: flex;
align-items: center;
}
}
img {
padding: 3px;
width: 16px;
height: 16px;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
}
.post-status-form { .post-status-form {
position: relative; position: relative;
.attachments {
margin-bottom: 0.5em;
}
.form-bottom { .form-bottom {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -507,15 +482,6 @@
flex-direction: column; flex-direction: column;
} }
.attachments .media-upload-wrapper {
position: relative;
.attachment {
margin: 0;
padding: 0;
}
}
.btn { .btn {
cursor: pointer; cursor: pointer;
} }
@ -616,11 +582,4 @@
border: 2px dashed var(--text, $fallback--text); border: 2px dashed var(--text, $fallback--text);
} }
} }
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
img.media-upload, .media-upload-container > video {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
</style> </style>

View file

@ -21,6 +21,7 @@ library.add(
const StatusContent = { const StatusContent = {
name: 'StatusContent', name: 'StatusContent',
props: [ props: [
'compact',
'status', 'status',
'focused', 'focused',
'noHeading', 'noHeading',

View file

@ -1,11 +1,17 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.StatusBody { .StatusBody {
display: flex;
flex-direction: column;
.emoji { .emoji {
--_still_image-label-scale: 0.5; --_still_image-label-scale: 0.5;
} }
.attachments {
margin-top: 0.5em;
}
& .text, & .text,
& .summary { & .summary {
font-family: var(--postFont, sans-serif); font-family: var(--postFont, sans-serif);
@ -115,4 +121,54 @@
.cyantext { .cyantext {
color: var(--postCyantext, $fallback--cBlue); color: var(--postCyantext, $fallback--cBlue);
} }
&.-compact {
align-items: top;
flex-direction: row;
--emoji-size: 16px;
& .body,
& .attachments {
max-height: 3.25em;
}
.body {
overflow: hidden;
white-space: normal;
min-width: 5em;
flex: 5 1 auto;
mask-size: auto 3.5em, auto auto;
mask-position: 0 0, 0 0;
mask-repeat: repeat-x, repeat;
mask-image: linear-gradient(to bottom, white 2em, transparent 3em);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.attachments {
margin-top: 0;
flex: 1 1 0;
min-width: 5em;
height: 100%;
margin-left: 0.5em;
}
.summary-wrapper {
.summary::after {
content: ': ';
}
line-height: inherit;
margin: 0;
border: none;
display: inline-block;
}
.text-wrapper {
display: inline-block;
}
}
} }

View file

@ -1,5 +1,8 @@
<template> <template>
<div class="StatusBody"> <div
class="StatusBody"
:class="{ '-compact': compact }"
>
<div class="body"> <div class="body">
<div <div
v-if="status.summary_raw_html" v-if="status.summary_raw_html"

View file

@ -28,6 +28,7 @@ const StatusContent = {
name: 'StatusContent', name: 'StatusContent',
props: [ props: [
'status', 'status',
'compact',
'focused', 'focused',
'noHeading', 'noHeading',
'fullContent', 'fullContent',
@ -58,24 +59,6 @@ const StatusContent = {
} }
return 'normal' return 'normal'
}, },
galleryTypes () {
if (this.attachmentSize === 'hide') {
return []
}
return this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
},
galleryAttachments () {
return this.status.attachments.filter(
file => fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
nonGalleryAttachments () {
return this.status.attachments.filter(
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
maxThumbnails () { maxThumbnails () {
return this.mergedConfig.maxThumbnails return this.mergedConfig.maxThumbnails
}, },
@ -90,12 +73,6 @@ const StatusContent = {
Gallery, Gallery,
LinkPreview, LinkPreview,
StatusBody StatusBody
},
methods: {
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)
}
} }
} }

View file

@ -1,8 +1,12 @@
<template> <template>
<div class="StatusContent"> <div
class="StatusContent"
:class="{ '-compact': compact }"
>
<slot name="header" /> <slot name="header" />
<StatusBody <StatusBody
:status="status" :status="status"
:compact="compact"
:single-line="singleLine" :single-line="singleLine"
:hide-mentions="hideMentions" :hide-mentions="hideMentions"
@parseReady="$emit('parseReady', $event)" @parseReady="$emit('parseReady', $event)"
@ -11,32 +15,20 @@
<poll :base-poll="status.poll" /> <poll :base-poll="status.poll" />
</div> </div>
<div <gallery
v-if="status.attachments.length !== 0"
class="attachments media-body" class="attachments media-body"
> v-if="status.attachments.length !== 0"
<attachment :nsfw="nsfwClickthrough"
v-for="attachment in nonGalleryAttachments" :attachments="status.attachments"
:key="attachment.id" :limit="this.compact ? 1 : 0"
class="non-gallery" :size="attachmentSize"
:size="attachmentSize" @setMedia="onMedia"
:nsfw="nsfwClickthrough" @play="$emit('mediaplay', attachment.id)"
:attachment="attachment" @pause="$emit('mediapause', attachment.id)"
:allow-play="true" />
:set-media="setMedia()"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:set-media="setMedia()"
/>
</div>
<div <div
v-if="status.card && !noHeading" v-if="status.card && !noHeading && !compact"
class="link-preview media-body" class="link-preview media-body"
> >
<link-preview <link-preview

View file

@ -159,7 +159,7 @@ export default {
mimetype: 'image' mimetype: 'image'
} }
this.$store.dispatch('setMedia', [attachment]) this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrent', attachment) this.$store.dispatch('setCurrentMedia', attachment)
}, },
mentionUser () { mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user }) this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })

View file

@ -723,7 +723,11 @@
"nsfw": "NSFW", "nsfw": "NSFW",
"expand": "Expand", "expand": "Expand",
"you": "(You)", "you": "(You)",
"plus_more": "+{number} more" "plus_more": "+{number} more",
"many_attachments": "Post has {number} attachment(s)",
"collapse_attachments": "Collapse attachments",
"show_all_attachments": "Show all attachments",
"open_gallery": "Open gallery"
}, },
"user_card": { "user_card": {
"approve": "Approve", "approve": "Approve",

View file

@ -1,4 +1,5 @@
import fileTypeService from '../services/file_type/file_type.service.js' import fileTypeService from '../services/file_type/file_type.service.js'
const supportedTypes = new Set(['image', 'video', 'audio', 'flash'])
const mediaViewer = { const mediaViewer = {
state: { state: {
@ -10,7 +11,7 @@ const mediaViewer = {
setMedia (state, media) { setMedia (state, media) {
state.media = media state.media = media
}, },
setCurrent (state, index) { setCurrentMedia (state, index) {
state.activated = true state.activated = true
state.currentIndex = index state.currentIndex = index
}, },
@ -22,13 +23,13 @@ const mediaViewer = {
setMedia ({ commit }, attachments) { setMedia ({ commit }, attachments) {
const media = attachments.filter(attachment => { const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype) const type = fileTypeService.fileType(attachment.mimetype)
return type === 'image' || type === 'video' || type === 'audio' return supportedTypes.has(type)
}) })
commit('setMedia', media) commit('setMedia', media)
}, },
setCurrent ({ commit, state }, current) { setCurrentMedia ({ commit, state }, current) {
const index = state.media.indexOf(current) const index = state.media.indexOf(current)
commit('setCurrent', index || 0) commit('setCurrentMedia', index || 0)
}, },
closeMediaViewer ({ commit }) { closeMediaViewer ({ commit }) {
commit('close') commit('close')