pixelfed/resources/assets/components/partials/post/PostEditModal.vue

593 lines
16 KiB
Vue

<template>
<b-modal
centered
v-model="isOpen"
body-class="p-0"
footer-class="d-flex justify-content-between align-items-center">
<template #modal-header="{ close }">
<div class="d-flex flex-grow-1 justify-content-between align-items-center">
<span style="width:40px;"></span>
<h5 class="font-weight-bold mb-0">Edit Post</h5>
<b-button size="sm" variant="link" @click="close()">
<i class="far fa-times text-dark fa-lg"></i>
</b-button>
</div>
</template>
<b-card
v-if="isLoading"
no-body
flush
class="shadow-none p-0">
<b-card-body style="min-height:300px" class="d-flex align-items-center justify-content-center">
<div class="d-flex justify-content-center align-items-center flex-column" style="gap: 0.4rem;">
<b-spinner variant="primary" />
<p class="small mb-0 font-weight-lighter">Loading Post...</p>
</div>
</b-card-body>
</b-card>
<b-card
v-else-if="!isLoading && isOpen && status && status.id"
no-body
flush
class="shadow-none p-0">
<b-card-header header-tag="nav">
<b-nav tabs fill card-header>
<b-nav-item :active="tabIndex === 0" @click="toggleTab(0)">Caption</b-nav-item>
<b-nav-item :active="tabIndex === 1" @click="toggleTab(1)">Media</b-nav-item>
<!-- <b-nav-item :active="tabIndex === 2" @click="toggleTab(2)">Audience</b-nav-item> -->
<b-nav-item :active="tabIndex === 4" @click="toggleTab(3)">Other</b-nav-item>
</b-nav>
</b-card-header>
<b-card-body style="min-height:300px">
<template v-if="tabIndex === 0">
<p class="font-weight-bold small">Caption</p>
<div class="media mb-0">
<div class="media-body">
<div class="form-group">
<label class="font-weight-bold text-muted small d-none">Caption</label>
<vue-tribute :options="tributeSettings">
<textarea
class="form-control border-0 rounded-0 no-focus"
rows="4"
placeholder="Write a caption..."
v-model="fields.caption"
:maxlength="config.uploader.max_caption_length"
v-on:keyup="composeTextLength = fields.caption.length"></textarea>
</vue-tribute>
<p class="help-text small text-right text-muted mb-0">{{composeTextLength}}/{{config.uploader.max_caption_length}}</p>
</div>
</div>
</div>
<hr />
<p class="font-weight-bold small">Sensitive/NSFW</p>
<div class="border py-2 px-3 bg-light rounded">
<b-form-checkbox v-model="fields.sensitive" name="check-button" switch style="font-weight:300">
<span class="ml-1 small">Contains spoilers, sensitive or nsfw content</span>
</b-form-checkbox>
</div>
<transition name="slide-fade">
<div v-if="fields.sensitive" class="form-group mt-3">
<label class="font-weight-bold small">Content Warning</label>
<textarea
class="form-control"
rows="2"
placeholder="Add an optional spoiler/content warning..."
:maxlength="140"
v-model="fields.spoiler_text"></textarea>
<p class="help-text small text-right text-muted mb-0">{{fields.spoiler_text ? fields.spoiler_text.length : 0}}/140</p>
</div>
</transition>
</template>
<template v-else-if="tabIndex === 1">
<div class="list-group">
<div
class="list-group-item"
v-for="(media, idx) in fields.media"
:key="'edm:' + media.id + ':' + idx">
<div class="d-flex justify-content-between align-items-center">
<template v-if="media.type === 'image'">
<img
:src="media.url"
width="40"
height="40"
style="object-fit: cover;"
class="bg-light rounded cursor-pointer"
@click="toggleLightbox"
/>
</template>
<p class="d-none d-lg-block mb-0"><span class="small font-weight-light">{{ media.mime }}</span></p>
<button
class="btn btn-sm font-weight-bold rounded-pill px-4"
style="font-size: 13px"
:class="[ media.description && media.description.length ? 'btn-success' : 'btn-outline-muted']"
@click.prevent="handleAddAltText(idx)"
>
{{ media.description && media.description.length ? 'Edit Alt Text' : 'Add Alt Text' }}
</button>
<div v-if="fields.media && fields.media.length > 1" class="btn-group">
<a
class="btn btn-outline-secondary btn-sm"
href="#"
:disabled="idx === 0"
:class="{ disabled: idx === 0}"
@click.prevent="toggleMediaOrder('prev', idx)">
<i class="fas fa-arrow-alt-up"></i>
</a>
<a
class="btn btn-outline-secondary btn-sm"
href="#"
:disabled="idx === fields.media.length - 1"
:class="{ disabled: idx === fields.media.length - 1}"
@click.prevent="toggleMediaOrder('next', idx)">
<i class="fas fa-arrow-alt-down"></i>
</a>
</div>
<button
class="btn btn-outline-danger btn-sm"
v-if="fields.media && fields.media.length && fields.media.length > 1"
@click.prevent="removeMedia(idx)">
<i class="far fa-trash-alt"></i>
</button>
</div>
<transition name="slide-fade">
<template v-if="altTextEditIndex === idx">
<div class="form-group mt-1">
<label class="font-weight-bold small">Alt Text</label>
<b-form-textarea
v-model="media.description"
placeholder="Describe your image for the visually impaired..."
rows="3"
max-rows="6"
@input="handleAltTextUpdate(idx)"
></b-form-textarea>
<div class="d-flex justify-content-between">
<a class="font-weight-bold small text-muted" href="#" @click.prevent="altTextEditIndex = undefined">Close</a>
<p class="help-text small mb-0">
{{ fields.media[idx].description ? fields.media[idx].description.length : 0 }}/{{config.uploader.max_altext_length}}
</p>
</div>
</div>
</template>
</transition>
</div>
</div>
</template>
<!-- <template v-else-if="tabIndex === 2">
<p class="font-weight-bold small">Audience</p>
<div class="list-group">
<div
v-if="!status.account.locked"
class="list-group-item font-weight-bold cursor-pointer"
:class="{ 'text-primary': fields.visibility == 'public' }"
@click="toggleVisibility('public')">
Public
<i v-if="fields.visibility == 'public'" class="far fa-check-circle ml-1"></i>
</div>
<div
v-if="!status.account.locked"
class="list-group-item font-weight-bold cursor-pointer"
:class="{ 'text-primary': fields.visibility == 'unlisted' }"
@click="toggleVisibility('unlisted')">
Unlisted
<i v-if="fields.visibility == 'unlisted'" class="far fa-check-circle ml-1"></i>
</div>
<div
class="list-group-item font-weight-bold cursor-pointer"
:class="{ 'text-primary': fields.visibility == 'private' }"
@click="toggleVisibility('private')">
Followers Only
<i v-if="fields.visibility == 'private'" class="far fa-check-circle ml-1"></i>
</div>
</div>
</template> -->
<template v-else-if="tabIndex === 3">
<p class="font-weight-bold small">Location</p>
<autocomplete
:search="locationSearch"
placeholder="Search locations ..."
aria-label="Search locations ..."
:get-result-value="getResultValue"
@submit="onSubmitLocation"
>
</autocomplete>
<div v-if="fields.location && fields.location.hasOwnProperty('id')" class="mt-3 border rounded p-3 d-flex justify-content-between">
<p class="font-weight-bold mb-0">
{{ fields.location.name }}, {{ fields.location.country}}
</p>
<button class="btn btn-link text-danger m-0 p-0" @click.prevent="clearLocation">
<i class="far fa-trash"></i>
</button>
</div>
</template>
</b-card-body>
</b-card>
<template
#modal-footer="{ ok, cancel, hide }">
<b-button class="rounded-pill px-3 font-weight-bold" variant="outline-muted" @click="cancel()">
Cancel
</b-button>
<b-button
class="rounded-pill font-weight-bold"
variant="primary"
style="min-width: 195px"
@click="handleSave"
:disabled="!canSave">
<template v-if="isSubmitting">
<b-spinner small />
</template>
<template v-else>
Save Updates
</template>
</b-button>
</template>
</b-modal>
</template>
<script type="text/javascript">
import Autocomplete from '@trevoreyre/autocomplete-vue';
import BigPicture from 'bigpicture';
export default {
components: {
Autocomplete,
},
data() {
return {
config: window.App.config,
status: undefined,
isLoading: true,
isOpen: false,
isSubmitting: false,
tabIndex: 0,
canEdit: false,
composeTextLength: 0,
canSave: false,
originalFields: {
caption: undefined,
visibility: undefined,
sensitive: undefined,
location: undefined,
spoiler_text: undefined,
media: [],
},
fields: {
caption: undefined,
visibility: undefined,
sensitive: undefined,
location: undefined,
spoiler_text: undefined,
media: [],
},
medias: undefined,
altTextEditIndex: undefined,
tributeSettings: {
noMatchTemplate: function () { return null; },
collection: [
{
trigger: '@',
menuShowMinLength: 2,
values: (function (text, cb) {
let url = '/api/compose/v0/search/mention';
axios.get(url, { params: { q: text }})
.then(res => {
cb(res.data);
})
.catch(err => {
console.log(err);
})
})
},
{
trigger: '#',
menuShowMinLength: 2,
values: (function (text, cb) {
let url = '/api/compose/v0/search/hashtag';
axios.get(url, { params: { q: text }})
.then(res => {
cb(res.data);
})
.catch(err => {
console.log(err);
})
})
}
]
},
}
},
watch: {
fields: {
deep: true,
immediate: true,
handler: function(n, o) {
if(!this.canEdit) {
return;
}
this.canSave = this.originalFields !== JSON.stringify(this.fields);
}
}
},
methods: {
reset() {
this.status = undefined;
this.tabIndex = 0;
this.isOpen = false;
this.canEdit = false;
this.composeTextLength = 0;
this.canSave = false;
this.originalFields = {
caption: undefined,
visibility: undefined,
sensitive: undefined,
location: undefined,
spoiler_text: undefined,
media: [],
};
this.fields = {
caption: undefined,
visibility: undefined,
sensitive: undefined,
location: undefined,
spoiler_text: undefined,
media: [],
};
this.medias = undefined;
this.altTextEditIndex = undefined;
this.isSubmitting = false;
},
async show(status) {
await axios.get('/api/v1/statuses/' + status.id, {
params: {
'_pe': 1
}
})
.then(res => {
this.reset();
this.init(res.data);
})
.finally(() => {
setTimeout(() => {
this.isLoading = false;
}, 500);
})
},
init(status) {
this.reset();
this.originalFields = JSON.stringify({
caption: status.content_text,
visibility: status.visibility,
sensitive: status.sensitive,
location: status.place,
spoiler_text: status.spoiler_text,
media: status.media_attachments
})
this.fields = {
caption: status.content_text,
visibility: status.visibility,
sensitive: status.sensitive,
location: status.place,
spoiler_text: status.spoiler_text,
media: status.media_attachments
}
this.status = status;
this.medias = status.media_attachments;
this.composeTextLength = status.content_text ? status.content_text.length : 0;
this.isOpen = true;
setTimeout(() => {
this.canEdit = true;
}, 1000);
},
toggleTab(idx) {
this.tabIndex = idx;
this.altTextEditIndex = undefined;
},
toggleVisibility(vis) {
this.fields.visibility = vis;
},
locationSearch(input) {
if (input.length < 1) { return []; }
let results = [];
return axios.get('/api/compose/v0/search/location', {
params: {
q: input
}
}).then(res => {
return res.data;
});
},
getResultValue(result) {
return result.name + ', ' + result.country
},
onSubmitLocation(result) {
this.fields.location = result;
this.tabIndex = 0;
},
clearLocation() {
event.currentTarget.blur();
this.fields.location = null;
this.tabIndex = 0;
},
handleAltTextUpdate(idx) {
if (this.fields.media[idx].description.length == 0) {
this.fields.media[idx].description = null;
}
},
moveMedia(from, to, arr) {
const newArr = [...arr];
const item = newArr.splice(from, 1)[0];
newArr.splice(to, 0, item);
return newArr;
},
toggleMediaOrder(dir, idx) {
if(dir === 'prev') {
this.fields.media = this.moveMedia(idx, idx - 1, this.fields.media);
}
if(dir === 'next') {
this.fields.media = this.moveMedia(idx, idx + 1, this.fields.media);
}
},
toggleLightbox(e) {
BigPicture({
el: e.target
})
},
handleAddAltText(idx) {
event.currentTarget.blur();
this.altTextEditIndex = idx
},
removeMedia(idx) {
swal({
title: 'Confirm',
text: 'Are you sure you want to remove this media from your post?',
buttons: {
cancel: "Cancel",
confirm: {
text: "Confirm Removal",
value: "remove",
className: "swal-button--danger"
}
}
})
.then((val) => {
if(val === 'remove') {
this.fields.media.splice(idx, 1);
}
})
},
async handleSave() {
event.currentTarget.blur();
this.canSave = false;
this.isSubmitting = true;
await this.checkMediaUpdates();
axios.put('/api/v1/statuses/' + this.status.id, {
status: this.fields.caption,
spoiler_text: this.fields.spoiler_text,
sensitive: this.fields.sensitive,
media_ids: this.fields.media.map(m => m.id),
location: this.fields.location
})
.then(res => {
this.isOpen = false;
this.$emit('update', res.data);
swal({
title: 'Post Updated',
text: 'You have successfully updated this post!',
icon: 'success',
buttons: {
close: {
text: "Close",
value: "close",
close: true,
className: "swal-button--cancel"
},
view: {
text: "View Post",
value: "view",
className: "btn-primary"
}
}
})
.then((val) => {
if(val === 'view') {
if(this.$router.currentRoute.name === 'post') {
window.location.reload();
} else {
this.$router.push('/i/web/post/' + this.status.id);
}
}
});
})
.catch(err => {
this.isSubmitting = false;
if(err.response.data.hasOwnProperty('error')) {
swal('Error', err.response.data.error, 'error');
} else {
swal('Error', 'An error occured, please try again later', 'error');
}
console.log(err);
})
},
async checkMediaUpdates() {
const cached = JSON.parse(this.originalFields);
const medias = JSON.stringify(cached.media);
if (medias !== JSON.stringify(this.fields.media)) {
await axios.all(this.fields.media.map((media) => this.updateAltText(media)))
}
},
async updateAltText(media) {
return await axios.put('/api/v1/media/' + media.id, {
description: media.description
});
}
}
}
</script>
<style lang="scss" scoped>
div, p {
font-family: var(--font-family-sans-serif);
}
.nav-link {
font-size: 13px;
font-weight: 600;
color: var(--text-lighter);
&.active {
font-weight: 800;
color: var(--primary);
}
}
.slide-fade-enter-active {
transition: all .5s ease;
}
.slide-fade-leave-active {
transition: all .2s cubic-bezier(0.5, 1.0, 0.6, 1.0);
}
.slide-fade-enter, .slide-fade-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>