mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
556 lines
22 KiB
Vue
556 lines
22 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();
|
|
|
|
await axios.get('/sanctum/csrf-cookie')
|
|
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>
|