Merge branch 'drafts-improvements' into 'develop'

Drafts improvements

See merge request pleroma/pleroma-fe!1964
This commit is contained in:
HJ 2024-12-31 11:17:22 +00:00
commit 5c6d29193e
13 changed files with 547 additions and 348 deletions

View file

View file

@ -390,6 +390,24 @@ nav {
}
}
.menu-item {
line-height: var(--__line-height);
font-family: inherit;
font-weight: 400;
font-size: 100%;
cursor: pointer;
a,
button:not(.button-default) {
color: var(--text);
font-size: 100%;
}
&.disabled {
cursor: not-allowed;
}
}
.menu-item,
.list-item {
display: block;
@ -397,10 +415,6 @@ nav {
border: none;
outline: none;
text-align: initial;
font-size: inherit;
font-family: inherit;
font-weight: 400;
cursor: pointer;
color: inherit;
clear: both;
position: relative;
@ -410,7 +424,6 @@ nav {
border-width: 0;
border-top-width: 1px;
width: 100%;
line-height: var(--__line-height);
padding: var(--__vertical-gap) var(--__horizontal-gap);
background: transparent;
@ -450,10 +463,8 @@ nav {
border: none;
outline: none;
display: inline;
font-size: 100%;
font-family: inherit;
line-height: unset;
color: var(--text);
}
&:first-child {

View file

@ -2,13 +2,25 @@ import PostStatusForm from 'src/components/post_status_form/post_status_form.vue
import EditStatusForm from 'src/components/edit_status_form/edit_status_form.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import { cloneDeep } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faPollH
} from '@fortawesome/free-solid-svg-icons'
library.add(
faPollH
)
const Draft = {
components: {
PostStatusForm,
EditStatusForm,
ConfirmModal,
StatusContent
StatusContent,
Gallery
},
props: {
draft: {
@ -18,6 +30,7 @@ const Draft = {
},
data () {
return {
referenceDraft: cloneDeep(this.draft),
editing: false,
showingConfirmDialog: false
}
@ -32,6 +45,11 @@ const Draft = {
return {}
}
},
safeToSave () {
return this.draft.status ||
this.draft.files?.length ||
this.draft.hasPoll
},
postStatusFormProps () {
return {
draftId: this.draft.id,
@ -40,6 +58,28 @@ const Draft = {
},
refStatus () {
return this.draft.refId ? this.$store.state.statuses.allStatusesObject[this.draft.refId] : undefined
},
localCollapseSubjectDefault () {
return this.$store.getters.mergedConfig.collapseMessageWithSubject
},
nsfwClickthrough () {
if (!this.draft.nsfw) {
return false
}
if (this.draft.summary && this.localCollapseSubjectDefault) {
return false
}
return true
}
},
watch: {
editing (newVal) {
if (newVal) return
if (this.safeToSave) {
this.$store.dispatch('addOrSaveDraft', { draft: this.draft })
} else {
this.$store.dispatch('addOrSaveDraft', { draft: this.referenceDraft })
}
}
},
methods: {

View file

@ -1,21 +1,5 @@
<template>
<article class="Draft">
<div class="actions">
<button
class="btn button-default"
:class="{ toggled: editing }"
:aria-expanded="editing"
@click.prevent.stop="toggleEditing"
>
{{ $t('drafts.continue') }}
</button>
<button
class="btn button-default"
@click.prevent.stop="abandon"
>
{{ $t('drafts.abandon') }}
</button>
</div>
<div
v-if="!editing"
class="status-content"
@ -42,15 +26,50 @@
:compact="true"
/>
</div>
<p>{{ draft.status }}</p>
<div class="status-preview">
<span class="status_content">
<p v-if="draft.spoilerText">
<i>
{{ draft.spoilerText }}:
</i>
</p>
<p v-if="draft.status">{{ draft.status }}</p>
<p v-else class="faint">{{ $t('drafts.empty') }}</p>
</span>
<gallery
v-if="draft.files?.length !== 0"
class="attachments media-body"
:compact="true"
:nsfw="nsfwClickthrough"
:attachments="draft.files"
:limit="1"
size="small"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
<div
v-if="draft.poll.options"
class="poll-indicator-container"
:title="$t('drafts.poll_tooltip')"
>
<div class="poll-indicator">
<FAIcon
icon="poll-h"
size="3x"
/>
</div>
</div>
</div>
</div>
<div v-if="editing">
<PostStatusForm
v-if="draft.type !== 'edit'"
:disable-draft="true"
v-bind="postStatusFormProps"
/>
<EditStatusForm
v-else
:disable-draft="true"
:params="postStatusFormProps"
/>
</div>
@ -66,6 +85,21 @@
{{ $t('drafts.abandon_confirm') }}
</confirm-modal>
</teleport>
<div class="actions">
<button
class="btn button-default"
:aria-expanded="editing"
@click.prevent.stop="toggleEditing"
>
{{ editing ? $t('drafts.save') : $t('drafts.continue') }}
</button>
<button
class="btn button-default"
@click.prevent.stop="abandon"
>
{{ $t('drafts.abandon') }}
</button>
</div>
</article>
</template>
@ -73,17 +107,55 @@
<style lang="scss">
.Draft {
margin: 1em;
position: relative;
.status-content {
border: 1px solid;
border-color: var(--faint);
border-radius: var(--inputRadius);
color: var(--text);
padding: 0.5em;
margin: 0.5em 0;
}
.status-preview {
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: 10em;
grid-auto-flow: column;
grid-gap: 0.5em;
align-items: start;
max-width: 100%;
p {
word-wrap: break-word;
white-space: normal;
overflow-x: hidden;
}
.poll-indicator-container {
border-radius: var(--roundness);
display: grid;
justify-items: center;
align-items: center;
align-self: start;
height: 0;
padding-bottom: 62.5%;
position: relative;
}
.poll-indicator {
box-sizing: border-box;
border: 1px solid var(--border);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
justify-items: center;
align-items: center;
width: 100%;
height: 100%;
}
}
.actions {
display: flex;
flex-direction: row;
@ -93,7 +165,6 @@
flex: 1;
margin-left: 1em;
margin-right: 1em;
max-width: 10em;
}
}
}

View file

@ -7,11 +7,17 @@
</div>
</div>
<div class="panel-body">
<p v-if="drafts.length === 0">
{{ $t('drafts.no_drafts') }}
</p>
<List
v-else
:items="drafts"
:non-interactive="true"
>
<template #item="{ item: draft }">
<Draft
class="draft"
:draft="draft"
/>
</template>
@ -22,3 +28,9 @@
</template>
<script src="./drafts.js"></script>
<style lang="scss">
.draft {
margin: 1em 0;
}
</style>

View file

@ -11,8 +11,9 @@ export default {
'Avatar'
],
states: {
hover: ':hover',
active: '.-active'
hover: ':hover:not(.disabled)',
active: '.-active',
disabled: '.disabled'
},
defaultRules: [
{
@ -85,6 +86,28 @@ export default {
textColor: '--link',
textAuto: 'no-preserve'
}
},
{
component: 'Text',
parent: {
component: 'MenuItem',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
},
{
component: 'Icon',
parent: {
component: 'MenuItem',
state: ['disabled']
},
directives: {
textOpacity: 0.25,
textOpacityMode: 'blend'
}
}
]
}

View file

@ -202,6 +202,9 @@
.title {
font-size: 1.3em;
margin-left: 0.6em;
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
}
}

View file

@ -154,11 +154,6 @@
font-size: 1.1em;
}
.timelines-chevron {
margin-left: 0.8em;
font-size: 1.1em;
}
.timelines-background {
padding: 0 0 0 0.6em;
}

View file

@ -82,8 +82,12 @@
--__horizontal-gap: 0.5em;
--__vertical-gap: 0.4em;
padding: 0;
display: flex;
padding: var(--__vertical-gap) var(--__horizontal-gap);
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: var(--__line-height);
grid-auto-flow: column;
grid-gap: var(--__horizontal-gap);
align-items: baseline;
&[aria-expanded] {
@ -93,8 +97,6 @@
.main-link {
line-height: var(--__line-height);
box-sizing: border-box;
flex: 1;
padding: var(--__vertical-gap) var(--__horizontal-gap);
}
.menu-icon {
@ -104,26 +106,16 @@
margin-right: var(--__horizontal-gap);
}
.timelines-chevron {
line-height: var(--__line-height);
padding: 0;
width: var(--__line-height);
margin-right: 0;
}
.timelines-chevron,
.extra-button {
line-height: var(--__line-height);
width: 100%;
padding: 0;
width: var(--__line-height);
text-align: center;
&:last-child {
margin-right: calc(-1 * var(--__horizontal-gap));
}
}
.badge {
margin: 0 var(--__horizontal-gap);
justify-self: center;
}
.iconEmoji {

View file

@ -7,6 +7,7 @@ import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import StatusContent from '../status_content/status_content.vue'
import Popover from 'src/components/popover/popover.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
@ -25,7 +26,10 @@ import {
faUpload,
faBan,
faTimes,
faCircleNotch
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
@ -34,7 +38,10 @@ library.add(
faUpload,
faBan,
faTimes,
faCircleNotch
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight
)
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
@ -111,7 +118,8 @@ const PostStatusForm = {
'resize',
'mediaplay',
'mediapause',
'can-close'
'can-close',
'update'
],
components: {
MediaUpload,
@ -123,7 +131,8 @@ const PostStatusForm = {
Attachment,
StatusContent,
Gallery,
DraftCloser
DraftCloser,
Popover
},
mounted () {
this.updateIdempotencyKey()
@ -210,7 +219,7 @@ const PostStatusForm = {
emojiInputShown: false,
idempotencyKey: '',
saveInhibited: true,
savable: false
saveable: false
}
},
computed: {
@ -335,7 +344,7 @@ const PostStatusForm = {
return this.$store.getters.mergedConfig.autoSaveDraft
},
autoSaveState () {
if (this.savable) {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
@ -343,6 +352,12 @@ const PostStatusForm = {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft () {
return this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll
},
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
@ -355,7 +370,7 @@ const PostStatusForm = {
this.statusChanged()
}
},
savable (val) {
saveable (val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
@ -374,7 +389,7 @@ const PostStatusForm = {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.savable = true
this.saveable = true
this.saveInhibited = false
},
clearStatus () {
@ -403,7 +418,7 @@ const PostStatusForm = {
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.savable = false
this.saveable = false
},
async postStatus (event, newStatus, opts = {}) {
if (this.posting && !this.optimisticPosting) { return }
@ -738,21 +753,19 @@ const PostStatusForm = {
saveDraft () {
if (!this.disableDraft &&
!this.saveInhibited) {
if (this.newStatus.status ||
this.newStatus.files?.length ||
this.newStatus.hasPoll) {
if (this.safeToSaveDraft) {
return this.$store.dispatch('addOrSaveDraft', { draft: this.newStatus })
.then(id => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.savable = false
this.saveable = false
})
} else if (this.newStatus.id) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft()
.then(() => {
this.savable = false
this.saveable = false
})
}
}
@ -780,7 +793,7 @@ const PostStatusForm = {
// No draft available, fall back
},
requestClose () {
if (!this.savable) {
if (!this.saveable) {
this.$emit('can-close')
} else {
this.$refs.draftCloser.requestClose()

View file

@ -0,0 +1,258 @@
.post-status-form {
position: relative;
.attachments {
margin-bottom: 0.5em;
}
.more-post-actions {
height: 100%;
.btn {
height: 100%;
}
}
.form-bottom {
display: flex;
justify-content: space-between;
padding: 0.5em;
height: 2.5em;
.post-button-group {
width: 10em;
display: flex;
.post-button {
flex: 1 0 auto;
}
.more-post-actions {
flex: 0 0 auto;
}
}
p {
margin: 0.35em;
padding: 0.35em;
display: flex;
}
}
.form-bottom-left {
display: flex;
flex: 1;
padding-right: 7px;
margin-right: 7px;
max-width: 10em;
}
.preview-heading {
display: flex;
flex-wrap: wrap;
}
.preview-toggle {
flex: 10 0 auto;
cursor: pointer;
user-select: none;
padding-left: 0.5em;
&:hover {
text-decoration: underline;
}
svg,
i {
margin-left: 0.2em;
font-size: 0.8em;
transform: rotate(90deg);
}
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: var(--textFaint);
}
.preview-status {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em;
margin: 0;
}
.reply-or-quote-selector {
flex: 1 0 auto;
margin-bottom: 0.5em;
display: grid;
grid-template-columns: 1fr 1fr;
}
.text-format {
.only-format {
color: var(--textFaint);
}
}
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
align-items: baseline;
}
.visibility-notice.edit-warning {
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
// Order is not necessary but a good indicator
.media-upload-icon {
order: 1;
justify-content: left;
}
.emoji-icon {
order: 2;
justify-content: center;
}
.poll-icon {
order: 3;
justify-content: right;
}
.media-upload-icon,
.poll-icon,
.emoji-icon {
font-size: 1.85em;
line-height: 1.1;
flex: 1;
padding: 0 0.1em;
display: flex;
align-items: center;
}
.error {
text-align: center;
}
.media-upload-wrapper {
margin-right: 0.2em;
margin-bottom: 0.5em;
width: 18em;
img,
video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
}
.status-input-wrapper {
display: flex;
position: relative;
width: 100%;
flex-direction: column;
}
.btn[disabled] {
cursor: not-allowed;
}
form {
display: flex;
flex-direction: column;
margin: 0.6em;
position: relative;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.25em 0.5em 0.5em;
line-height: 1.85;
}
.input.form-post-body {
// TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size
overflow: hidden;
transition: min-height 200ms 100ms;
// stock padding + 1 line of text (for counter)
padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
// two lines of text
height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em);
resize: none;
background: transparent;
&.scrollable-form {
overflow-y: auto;
}
}
.main-input {
position: relative;
}
.character-counter {
position: absolute;
bottom: 0;
right: 0;
padding: 0;
margin: 0 0.5em;
&.error {
color: var(--cRed);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: var(--text);
background-color: var(--bg);
border-radius: var(--roundness);
border: 2px dashed var(--text);
}
.auto-save-status {
align-self: center;
}
}

View file

@ -264,6 +264,12 @@
:visible="pollFormVisible"
:params="newStatus.poll"
/>
<span
v-if="!disableDraft && shouldAutoSaveDraft"
class="auto-save-status"
>
{{ autoSaveState }}
</span>
<div
ref="bottom"
class="form-bottom"
@ -296,41 +302,54 @@
<FAIcon icon="poll-h" />
</button>
</div>
<span
v-if="!disableDraft && shouldAutoSaveDraft"
class="auto-save-status"
>
{{ autoSaveState }}
</span>
<button
v-else-if="!disableDraft"
class="btn button-default"
@click="saveDraft"
>
{{ $t('post_status.save_to_drafts_button') }}
</button>
<button
v-if="posting"
disabled
class="btn button-default"
>
{{ $t('post_status.posting') }}
</button>
<button
v-else-if="isOverLengthLimit"
disabled
class="btn button-default"
>
{{ $t('post_status.post') }}
</button>
<button
v-else
:disabled="uploadingFiles || disableSubmit"
class="btn button-default"
@click.stop.prevent="postStatus($event, newStatus)"
>
{{ $t('post_status.post') }}
</button>
<div class="btn-group post-button-group">
<button
class="btn button-default post-button"
:disabled="isOverLengthLimit || posting || uploadingFiles || disableSubmit"
@click.stop.prevent="postStatus($event, newStatus)"
>
<template v-if="posting">
{{ $t('post_status.posting') }}
</template>
<template v-else>
{{ $t('post_status.post') }}
</template>
</button>
<Popover
v-if="!disableDraft"
class="more-post-actions"
:normal-button="true"
trigger="click"
placement="bottom"
:offset="{ y: 5 }"
:bound-to="{ x: 'container' }"
>
<template #trigger>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="chevron-down"
/>
</template>
<template #content="{close}">
<div
class="dropdown-menu"
role="menu"
>
<button
v-if="!disableDraft"
class="menu-item dropdown-item dropdown-item-icon"
role="menu"
:disabled="!safeToSaveDraft"
:class="{ disabled: !safeToSaveDraft }"
@click.prevent="saveDraft"
@click="close"
>
{{ $t('post_status.save_to_drafts_button') }}
</button>
</div>
</template>
</Popover>
</div>
</div>
<div
v-show="showDropIcon !== 'hide'"
@ -391,246 +410,4 @@
<script src="./post_status_form.js"></script>
<style lang="scss">
.post-status-form {
position: relative;
.attachments {
margin-bottom: 0.5em;
}
.form-bottom {
display: flex;
justify-content: space-between;
padding: 0.5em;
height: 2.5em;
button {
width: 10em;
}
p {
margin: 0.35em;
padding: 0.35em;
display: flex;
}
}
.form-bottom-left {
display: flex;
flex: 1;
padding-right: 7px;
margin-right: 7px;
max-width: 10em;
}
.preview-heading {
display: flex;
flex-wrap: wrap;
}
.preview-toggle {
flex: 10 0 auto;
cursor: pointer;
user-select: none;
padding-left: 0.5em;
&:hover {
text-decoration: underline;
}
svg,
i {
margin-left: 0.2em;
font-size: 0.8em;
transform: rotate(90deg);
}
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: var(--textFaint);
}
.preview-status {
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em;
margin: 0;
}
.reply-or-quote-selector {
flex: 1 0 auto;
margin-bottom: 0.5em;
display: grid;
grid-template-columns: 1fr 1fr;
}
.text-format {
.only-format {
color: var(--textFaint);
}
}
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
align-items: baseline;
}
.visibility-notice.edit-warning {
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
// Order is not necessary but a good indicator
.media-upload-icon {
order: 1;
justify-content: left;
}
.emoji-icon {
order: 2;
justify-content: center;
}
.poll-icon {
order: 3;
justify-content: right;
}
.media-upload-icon,
.poll-icon,
.emoji-icon {
font-size: 1.85em;
line-height: 1.1;
flex: 1;
padding: 0 0.1em;
display: flex;
align-items: center;
}
.error {
text-align: center;
}
.media-upload-wrapper {
margin-right: 0.2em;
margin-bottom: 0.5em;
width: 18em;
img,
video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
}
.status-input-wrapper {
display: flex;
position: relative;
width: 100%;
flex-direction: column;
}
.btn[disabled] {
cursor: not-allowed;
}
form {
display: flex;
flex-direction: column;
margin: 0.6em;
position: relative;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.25em 0.5em 0.5em;
line-height: 1.85;
}
.input.form-post-body {
// TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size
overflow: hidden;
transition: min-height 200ms 100ms;
// stock padding + 1 line of text (for counter)
padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em);
// two lines of text
height: calc(var(--post-line-height) * 1em);
min-height: calc(var(--post-line-height) * 1em);
resize: none;
background: transparent;
&.scrollable-form {
overflow-y: auto;
}
}
.main-input {
position: relative;
}
.character-counter {
position: absolute;
bottom: 0;
right: 0;
padding: 0;
margin: 0 0.5em;
&.error {
color: var(--cRed);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
@keyframes fade-out {
from { opacity: 0.6; }
to { opacity: 0; }
}
.drop-indicator {
position: absolute;
width: 100%;
height: 100%;
font-size: 5em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
color: var(--text);
background-color: var(--bg);
border-radius: var(--roundness);
border: 2px dashed var(--text);
}
.auto-save-status {
align-self: center;
}
}
</style>
<style src="./post_status_form.scss" lang="scss"></style>

View file

@ -1513,7 +1513,11 @@
},
"drafts": {
"drafts": "Drafts",
"no_drafts": "You have no drafts",
"empty": "(No content)",
"poll_tooltip": "Draft contains a poll",
"continue": "Continue composing",
"save": "Save without posting",
"abandon": "Abandon draft",
"abandon_confirm_title": "Abandon confirmation",
"abandon_confirm": "Do you really want to abandon this draft?",