1
0

PostEditModal.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. <template>
  2. <b-modal
  3. centered
  4. v-model="isOpen"
  5. body-class="p-0"
  6. footer-class="d-flex justify-content-between align-items-center">
  7. <template #modal-header="{ close }">
  8. <div class="d-flex flex-grow-1 justify-content-between align-items-center">
  9. <span style="width:40px;"></span>
  10. <h5 class="font-weight-bold mb-0">Edit Post</h5>
  11. <b-button size="sm" variant="link" @click="close()">
  12. <i class="far fa-times text-dark fa-lg"></i>
  13. </b-button>
  14. </div>
  15. </template>
  16. <b-card
  17. v-if="isLoading"
  18. no-body
  19. flush
  20. class="shadow-none p-0">
  21. <b-card-body style="min-height:300px" class="d-flex align-items-center justify-content-center">
  22. <div class="d-flex justify-content-center align-items-center flex-column" style="gap: 0.4rem;">
  23. <b-spinner variant="primary" />
  24. <p class="small mb-0 font-weight-lighter">Loading Post...</p>
  25. </div>
  26. </b-card-body>
  27. </b-card>
  28. <b-card
  29. v-else-if="!isLoading && isOpen && status && status.id"
  30. no-body
  31. flush
  32. class="shadow-none p-0">
  33. <b-card-header header-tag="nav">
  34. <b-nav tabs fill card-header>
  35. <b-nav-item :active="tabIndex === 0" @click="toggleTab(0)">Caption</b-nav-item>
  36. <b-nav-item :active="tabIndex === 1" @click="toggleTab(1)">Media</b-nav-item>
  37. <!-- <b-nav-item :active="tabIndex === 2" @click="toggleTab(2)">Audience</b-nav-item> -->
  38. <b-nav-item :active="tabIndex === 4" @click="toggleTab(3)">Other</b-nav-item>
  39. </b-nav>
  40. </b-card-header>
  41. <b-card-body style="min-height:300px">
  42. <template v-if="tabIndex === 0">
  43. <p class="font-weight-bold small">Caption</p>
  44. <div class="media mb-0">
  45. <div class="media-body">
  46. <div class="form-group">
  47. <label class="font-weight-bold text-muted small d-none">Caption</label>
  48. <vue-tribute :options="tributeSettings">
  49. <textarea
  50. class="form-control border-0 rounded-0 no-focus"
  51. rows="4"
  52. placeholder="Write a caption..."
  53. v-model="fields.caption"
  54. :maxlength="config.uploader.max_caption_length"
  55. v-on:keyup="composeTextLength = fields.caption.length"></textarea>
  56. </vue-tribute>
  57. <p class="help-text small text-right text-muted mb-0">{{composeTextLength}}/{{config.uploader.max_caption_length}}</p>
  58. </div>
  59. </div>
  60. </div>
  61. <hr />
  62. <p class="font-weight-bold small">Sensitive/NSFW</p>
  63. <div class="border py-2 px-3 bg-light rounded">
  64. <b-form-checkbox v-model="fields.sensitive" name="check-button" switch style="font-weight:300">
  65. <span class="ml-1 small">Contains spoilers, sensitive or nsfw content</span>
  66. </b-form-checkbox>
  67. </div>
  68. <transition name="slide-fade">
  69. <div v-if="fields.sensitive" class="form-group mt-3">
  70. <label class="font-weight-bold small">Content Warning</label>
  71. <textarea
  72. class="form-control"
  73. rows="2"
  74. placeholder="Add an optional spoiler/content warning..."
  75. :maxlength="140"
  76. v-model="fields.spoiler_text"></textarea>
  77. <p class="help-text small text-right text-muted mb-0">{{fields.spoiler_text ? fields.spoiler_text.length : 0}}/140</p>
  78. </div>
  79. </transition>
  80. </template>
  81. <template v-else-if="tabIndex === 1">
  82. <div class="list-group">
  83. <div
  84. class="list-group-item"
  85. v-for="(media, idx) in fields.media"
  86. :key="'edm:' + media.id + ':' + idx">
  87. <div class="d-flex justify-content-between align-items-center">
  88. <template v-if="media.type === 'image'">
  89. <img
  90. :src="media.url"
  91. width="40"
  92. height="40"
  93. style="object-fit: cover;"
  94. class="bg-light rounded cursor-pointer"
  95. @click="toggleLightbox"
  96. />
  97. </template>
  98. <p class="d-none d-lg-block mb-0"><span class="small font-weight-light">{{ media.mime }}</span></p>
  99. <button
  100. class="btn btn-sm font-weight-bold rounded-pill px-4"
  101. style="font-size: 13px"
  102. :class="[ media.description && media.description.length ? 'btn-success' : 'btn-outline-muted']"
  103. @click.prevent="handleAddAltText(idx)"
  104. >
  105. {{ media.description && media.description.length ? 'Edit Alt Text' : 'Add Alt Text' }}
  106. </button>
  107. <div v-if="fields.media && fields.media.length > 1" class="btn-group">
  108. <a
  109. class="btn btn-outline-secondary btn-sm"
  110. href="#"
  111. :disabled="idx === 0"
  112. :class="{ disabled: idx === 0}"
  113. @click.prevent="toggleMediaOrder('prev', idx)">
  114. <i class="fas fa-arrow-alt-up"></i>
  115. </a>
  116. <a
  117. class="btn btn-outline-secondary btn-sm"
  118. href="#"
  119. :disabled="idx === fields.media.length - 1"
  120. :class="{ disabled: idx === fields.media.length - 1}"
  121. @click.prevent="toggleMediaOrder('next', idx)">
  122. <i class="fas fa-arrow-alt-down"></i>
  123. </a>
  124. </div>
  125. <button
  126. class="btn btn-outline-danger btn-sm"
  127. v-if="fields.media && fields.media.length && fields.media.length > 1"
  128. @click.prevent="removeMedia(idx)">
  129. <i class="far fa-trash-alt"></i>
  130. </button>
  131. </div>
  132. <transition name="slide-fade">
  133. <template v-if="altTextEditIndex === idx">
  134. <div class="form-group mt-1">
  135. <label class="font-weight-bold small">Alt Text</label>
  136. <b-form-textarea
  137. v-model="media.description"
  138. placeholder="Describe your image for the visually impaired..."
  139. rows="3"
  140. max-rows="6"
  141. @input="handleAltTextUpdate(idx)"
  142. ></b-form-textarea>
  143. <div class="d-flex justify-content-between">
  144. <a class="font-weight-bold small text-muted" href="#" @click.prevent="altTextEditIndex = undefined">Close</a>
  145. <p class="help-text small mb-0">
  146. {{ fields.media[idx].description ? fields.media[idx].description.length : 0 }}/{{config.uploader.max_altext_length}}
  147. </p>
  148. </div>
  149. </div>
  150. </template>
  151. </transition>
  152. </div>
  153. </div>
  154. </template>
  155. <!-- <template v-else-if="tabIndex === 2">
  156. <p class="font-weight-bold small">Audience</p>
  157. <div class="list-group">
  158. <div
  159. v-if="!status.account.locked"
  160. class="list-group-item font-weight-bold cursor-pointer"
  161. :class="{ 'text-primary': fields.visibility == 'public' }"
  162. @click="toggleVisibility('public')">
  163. Public
  164. <i v-if="fields.visibility == 'public'" class="far fa-check-circle ml-1"></i>
  165. </div>
  166. <div
  167. v-if="!status.account.locked"
  168. class="list-group-item font-weight-bold cursor-pointer"
  169. :class="{ 'text-primary': fields.visibility == 'unlisted' }"
  170. @click="toggleVisibility('unlisted')">
  171. Unlisted
  172. <i v-if="fields.visibility == 'unlisted'" class="far fa-check-circle ml-1"></i>
  173. </div>
  174. <div
  175. class="list-group-item font-weight-bold cursor-pointer"
  176. :class="{ 'text-primary': fields.visibility == 'private' }"
  177. @click="toggleVisibility('private')">
  178. Followers Only
  179. <i v-if="fields.visibility == 'private'" class="far fa-check-circle ml-1"></i>
  180. </div>
  181. </div>
  182. </template> -->
  183. <template v-else-if="tabIndex === 3">
  184. <p class="font-weight-bold small">Location</p>
  185. <autocomplete
  186. :search="locationSearch"
  187. placeholder="Search locations ..."
  188. aria-label="Search locations ..."
  189. :get-result-value="getResultValue"
  190. @submit="onSubmitLocation"
  191. >
  192. </autocomplete>
  193. <div v-if="fields.location && fields.location.hasOwnProperty('id')" class="mt-3 border rounded p-3 d-flex justify-content-between">
  194. <p class="font-weight-bold mb-0">
  195. {{ fields.location.name }}, {{ fields.location.country}}
  196. </p>
  197. <button class="btn btn-link text-danger m-0 p-0" @click.prevent="clearLocation">
  198. <i class="far fa-trash"></i>
  199. </button>
  200. </div>
  201. </template>
  202. </b-card-body>
  203. </b-card>
  204. <template
  205. #modal-footer="{ ok, cancel, hide }">
  206. <b-button class="rounded-pill px-3 font-weight-bold" variant="outline-muted" @click="cancel()">
  207. Cancel
  208. </b-button>
  209. <b-button
  210. class="rounded-pill font-weight-bold"
  211. variant="primary"
  212. style="min-width: 195px"
  213. @click="handleSave"
  214. :disabled="!canSave">
  215. <template v-if="isSubmitting">
  216. <b-spinner small />
  217. </template>
  218. <template v-else>
  219. Save Updates
  220. </template>
  221. </b-button>
  222. </template>
  223. </b-modal>
  224. </template>
  225. <script type="text/javascript">
  226. import Autocomplete from '@trevoreyre/autocomplete-vue';
  227. import BigPicture from 'bigpicture';
  228. export default {
  229. components: {
  230. Autocomplete,
  231. },
  232. data() {
  233. return {
  234. config: window.App.config,
  235. status: undefined,
  236. isLoading: true,
  237. isOpen: false,
  238. isSubmitting: false,
  239. tabIndex: 0,
  240. canEdit: false,
  241. composeTextLength: 0,
  242. canSave: false,
  243. originalFields: {
  244. caption: undefined,
  245. visibility: undefined,
  246. sensitive: undefined,
  247. location: undefined,
  248. spoiler_text: undefined,
  249. media: [],
  250. },
  251. fields: {
  252. caption: undefined,
  253. visibility: undefined,
  254. sensitive: undefined,
  255. location: undefined,
  256. spoiler_text: undefined,
  257. media: [],
  258. },
  259. medias: undefined,
  260. altTextEditIndex: undefined,
  261. tributeSettings: {
  262. noMatchTemplate: function () { return null; },
  263. collection: [
  264. {
  265. trigger: '@',
  266. menuShowMinLength: 2,
  267. values: (function (text, cb) {
  268. let url = '/api/compose/v0/search/mention';
  269. axios.get(url, { params: { q: text }})
  270. .then(res => {
  271. cb(res.data);
  272. })
  273. .catch(err => {
  274. console.log(err);
  275. })
  276. })
  277. },
  278. {
  279. trigger: '#',
  280. menuShowMinLength: 2,
  281. values: (function (text, cb) {
  282. let url = '/api/compose/v0/search/hashtag';
  283. axios.get(url, { params: { q: text }})
  284. .then(res => {
  285. cb(res.data);
  286. })
  287. .catch(err => {
  288. console.log(err);
  289. })
  290. })
  291. }
  292. ]
  293. },
  294. }
  295. },
  296. watch: {
  297. fields: {
  298. deep: true,
  299. immediate: true,
  300. handler: function(n, o) {
  301. if(!this.canEdit) {
  302. return;
  303. }
  304. this.canSave = this.originalFields !== JSON.stringify(this.fields);
  305. }
  306. }
  307. },
  308. methods: {
  309. reset() {
  310. this.status = undefined;
  311. this.tabIndex = 0;
  312. this.isOpen = false;
  313. this.canEdit = false;
  314. this.composeTextLength = 0;
  315. this.canSave = false;
  316. this.originalFields = {
  317. caption: undefined,
  318. visibility: undefined,
  319. sensitive: undefined,
  320. location: undefined,
  321. spoiler_text: undefined,
  322. media: [],
  323. };
  324. this.fields = {
  325. caption: undefined,
  326. visibility: undefined,
  327. sensitive: undefined,
  328. location: undefined,
  329. spoiler_text: undefined,
  330. media: [],
  331. };
  332. this.medias = undefined;
  333. this.altTextEditIndex = undefined;
  334. this.isSubmitting = false;
  335. },
  336. async show(status) {
  337. await axios.get('/api/v1/statuses/' + status.id, {
  338. params: {
  339. '_pe': 1
  340. }
  341. })
  342. .then(res => {
  343. this.reset();
  344. this.init(res.data);
  345. })
  346. .finally(() => {
  347. setTimeout(() => {
  348. this.isLoading = false;
  349. }, 500);
  350. })
  351. },
  352. init(status) {
  353. this.reset();
  354. this.originalFields = JSON.stringify({
  355. caption: status.content_text,
  356. visibility: status.visibility,
  357. sensitive: status.sensitive,
  358. location: status.place,
  359. spoiler_text: status.spoiler_text,
  360. media: status.media_attachments
  361. })
  362. this.fields = {
  363. caption: status.content_text,
  364. visibility: status.visibility,
  365. sensitive: status.sensitive,
  366. location: status.place,
  367. spoiler_text: status.spoiler_text,
  368. media: status.media_attachments
  369. }
  370. this.status = status;
  371. this.medias = status.media_attachments;
  372. this.composeTextLength = status.content_text ? status.content_text.length : 0;
  373. this.isOpen = true;
  374. setTimeout(() => {
  375. this.canEdit = true;
  376. }, 1000);
  377. },
  378. toggleTab(idx) {
  379. this.tabIndex = idx;
  380. this.altTextEditIndex = undefined;
  381. },
  382. toggleVisibility(vis) {
  383. this.fields.visibility = vis;
  384. },
  385. locationSearch(input) {
  386. if (input.length < 1) { return []; }
  387. let results = [];
  388. return axios.get('/api/compose/v0/search/location', {
  389. params: {
  390. q: input
  391. }
  392. }).then(res => {
  393. return res.data;
  394. });
  395. },
  396. getResultValue(result) {
  397. return result.name + ', ' + result.country
  398. },
  399. onSubmitLocation(result) {
  400. this.fields.location = result;
  401. this.tabIndex = 0;
  402. },
  403. clearLocation() {
  404. event.currentTarget.blur();
  405. this.fields.location = null;
  406. this.tabIndex = 0;
  407. },
  408. handleAltTextUpdate(idx) {
  409. if (this.fields.media[idx].description.length == 0) {
  410. this.fields.media[idx].description = null;
  411. }
  412. },
  413. moveMedia(from, to, arr) {
  414. const newArr = [...arr];
  415. const item = newArr.splice(from, 1)[0];
  416. newArr.splice(to, 0, item);
  417. return newArr;
  418. },
  419. toggleMediaOrder(dir, idx) {
  420. if(dir === 'prev') {
  421. this.fields.media = this.moveMedia(idx, idx - 1, this.fields.media);
  422. }
  423. if(dir === 'next') {
  424. this.fields.media = this.moveMedia(idx, idx + 1, this.fields.media);
  425. }
  426. },
  427. toggleLightbox(e) {
  428. BigPicture({
  429. el: e.target
  430. })
  431. },
  432. handleAddAltText(idx) {
  433. event.currentTarget.blur();
  434. this.altTextEditIndex = idx
  435. },
  436. removeMedia(idx) {
  437. swal({
  438. title: 'Confirm',
  439. text: 'Are you sure you want to remove this media from your post?',
  440. buttons: {
  441. cancel: "Cancel",
  442. confirm: {
  443. text: "Confirm Removal",
  444. value: "remove",
  445. className: "swal-button--danger"
  446. }
  447. }
  448. })
  449. .then((val) => {
  450. if(val === 'remove') {
  451. this.fields.media.splice(idx, 1);
  452. }
  453. })
  454. },
  455. async handleSave() {
  456. event.currentTarget.blur();
  457. this.canSave = false;
  458. this.isSubmitting = true;
  459. await this.checkMediaUpdates();
  460. axios.put('/api/v1/statuses/' + this.status.id, {
  461. status: this.fields.caption,
  462. spoiler_text: this.fields.spoiler_text,
  463. sensitive: this.fields.sensitive,
  464. media_ids: this.fields.media.map(m => m.id),
  465. location: this.fields.location
  466. })
  467. .then(res => {
  468. this.isOpen = false;
  469. this.$emit('update', res.data);
  470. swal({
  471. title: 'Post Updated',
  472. text: 'You have successfully updated this post!',
  473. icon: 'success',
  474. buttons: {
  475. close: {
  476. text: "Close",
  477. value: "close",
  478. close: true,
  479. className: "swal-button--cancel"
  480. },
  481. view: {
  482. text: "View Post",
  483. value: "view",
  484. className: "btn-primary"
  485. }
  486. }
  487. })
  488. .then((val) => {
  489. if(val === 'view') {
  490. if(this.$router.currentRoute.name === 'post') {
  491. window.location.reload();
  492. } else {
  493. this.$router.push('/i/web/post/' + this.status.id);
  494. }
  495. }
  496. });
  497. })
  498. .catch(err => {
  499. this.isSubmitting = false;
  500. if(err.response.data.hasOwnProperty('error')) {
  501. swal('Error', err.response.data.error, 'error');
  502. } else {
  503. swal('Error', 'An error occured, please try again later', 'error');
  504. }
  505. console.log(err);
  506. })
  507. },
  508. async checkMediaUpdates() {
  509. const cached = JSON.parse(this.originalFields);
  510. const medias = JSON.stringify(cached.media);
  511. if (medias !== JSON.stringify(this.fields.media)) {
  512. await axios.all(this.fields.media.map((media) => this.updateAltText(media)))
  513. }
  514. },
  515. async updateAltText(media) {
  516. return await axios.put('/api/v1/media/' + media.id, {
  517. description: media.description
  518. });
  519. }
  520. }
  521. }
  522. </script>
  523. <style lang="scss" scoped>
  524. div, p {
  525. font-family: var(--font-family-sans-serif);
  526. }
  527. .nav-link {
  528. font-size: 13px;
  529. font-weight: 600;
  530. color: var(--text-lighter);
  531. &.active {
  532. font-weight: 800;
  533. color: var(--primary);
  534. }
  535. }
  536. .slide-fade-enter-active {
  537. transition: all .5s ease;
  538. }
  539. .slide-fade-leave-active {
  540. transition: all .2s cubic-bezier(0.5, 1.0, 0.6, 1.0);
  541. }
  542. .slide-fade-enter, .slide-fade-leave-to {
  543. transform: translateY(20px);
  544. opacity: 0;
  545. }
  546. </style>