StoryCompose.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. <template>
  2. <div class="story-compose-component container mt-2 mt-md-5 bg-black">
  3. <input type="file" id="pf-dz" name="media" class="d-none file-input" v-bind:accept="config.mimes">
  4. <span class="fixed-top text-right m-3 cursor-pointer" @click="navigateTo()">
  5. <i class="fal fa-times-circle fa-2x text-lighter"></i>
  6. </span>
  7. <div v-if="loaded" class="row">
  8. <div class="col-12 col-md-6 offset-md-3 bg-dark rounded-lg px-0">
  9. <!-- LANDING -->
  10. <div v-if="page == 'landing'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
  11. <div class="text-center flex-fill pt-3">
  12. <img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
  13. <p class="lead text-lighter font-weight-light mb-0">Stories</p>
  14. </div>
  15. <div class="flex-fill py-4">
  16. <p class="text-center lead font-weight-light text-lighter mb-4">Share moments with followers that last 24 hours</p>
  17. <div class="card w-100 shadow-none bg-transparent">
  18. <div class="d-flex">
  19. <button type="button" class="btn btn-outline-light btn-lg font-weight-bold btn-block rounded-pill my-1" :disabled="stories.length >= 20" @click="upload()">
  20. Add to Story
  21. </button>
  22. <!-- <button :disabled="stories.length >= 20" type="button" class="btn btn-outline-light btn-lg font-weight-bold btn-block rounded-pill my-1 ml-2" @click="newPoll">
  23. Create Poll
  24. </button> -->
  25. </div>
  26. <p
  27. v-if="stories.length >= 20"
  28. class="font-weight-bold text-muted text-center">
  29. You have reached the limit for new stories
  30. </p>
  31. <button
  32. type="button"
  33. class="btn btn-outline-light btn-lg font-weight-bold btn-block rounded-pill my-3"
  34. @click="viewMyStory"
  35. :disabled="stories.length == 0">
  36. <span>My Story</span>
  37. <sup v-if="stories.length" class="ml-2 px-2 text-light bg-danger rounded-pill" style="font-size: 12px;padding-top:2px;padding-bottom:3px;">{{ stories.length }}</sup>
  38. </button>
  39. </div>
  40. </div>
  41. <div class="text-center flex-fill">
  42. <p class="text-uppercase mb-0">
  43. <a href="/" class="text-lighter font-weight-bold">Home</a>
  44. <span class="px-2 text-lighter">|</span>
  45. <a href="/site/help" class="text-lighter font-weight-bold">Help</a>
  46. </p>
  47. <p class="small text-muted mb-0">v 1.0.0</p>
  48. </div>
  49. </div>
  50. <div v-else-if="page == 'crop'" class="d-flex justify-content-center flex-fill" style="position: relative;height: 90vh;">
  51. <vue-cropper
  52. class="w-100 h-100 p-0"
  53. ref="croppa"
  54. :aspectRatio="cropper.aspectRatio"
  55. :viewMode="3"
  56. :dragMode="'move'"
  57. :autoCropArea="1"
  58. :guides="false"
  59. :highlight="false"
  60. :cropBoxMovable="false"
  61. :cropBoxResizable="false"
  62. :toggleDragModeOnDblclick="false"
  63. :src="mediaUrl"
  64. >
  65. </vue-cropper>
  66. <div class="crop-container">
  67. <div class="d-flex justify-content-between align-items-center">
  68. <button
  69. type="button"
  70. class="btn btn-outline-muted rounded-pill font-weight-bold px-4"
  71. @click="deleteCurrentStory()">
  72. Cancel
  73. </button>
  74. <div class="text-center">
  75. <h4 class="font-weight-light text-light mb-n1">Crop</h4>
  76. <span class="small text-light">Pan around and pinch to zoom</span>
  77. </div>
  78. <button
  79. type="button"
  80. class="btn btn-outline-light rounded-pill font-weight-bold px-4"
  81. @click="performCrop()">
  82. Next
  83. </button>
  84. </div>
  85. </div>
  86. </div>
  87. <div v-else-if="page == 'error'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
  88. <div class="text-center flex-fill pt-3">
  89. <img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
  90. <p class="lead text-lighter font-weight-light mb-0">Stories</p>
  91. </div>
  92. <div class="flex-fill text-center">
  93. <p class="h3 mb-0 text-light">Oops!</p>
  94. <p class="text-muted lead">An error occurred, please try again later.</p>
  95. <p class="text-muted mb-0">
  96. <a class="btn btn-outline-muted py-0 px-5 rounded-pill font-weight-bold" href="/">Go back</a>
  97. </p>
  98. </div>
  99. </div>
  100. <div v-else-if="page == 'uploading'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
  101. <div class="spinner-border text-lighter" role="status">
  102. <span class="sr-only">Loading...</span>
  103. </div>
  104. </div>
  105. <div v-else-if="page == 'cropping'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
  106. <div class="spinner-border text-lighter" role="status">
  107. <span class="sr-only">Loading...</span>
  108. </div>
  109. </div>
  110. <div v-else-if="page == 'preview'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
  111. <div class="text-center flex-fill pt-3">
  112. <img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
  113. <p class="lead text-lighter font-weight-light mb-0">Stories</p>
  114. </div>
  115. <div class="flex-fill">
  116. <div class="form-group pb-3">
  117. <label for="durationSlider" class="text-light lead font-weight-bold">Options</label>
  118. <div class="custom-control custom-checkbox mb-2">
  119. <input type="checkbox" class="custom-control-input" id="optionReplies" v-model="canReply">
  120. <label class="custom-control-label text-light font-weight-lighter" for="optionReplies">Allow replies</label>
  121. </div>
  122. <div class="custom-control custom-checkbox mb-2">
  123. <input type="checkbox" class="custom-control-input" id="formReactions" v-model="canReact">
  124. <label class="custom-control-label text-light font-weight-lighter" for="formReactions">Allow reactions</label>
  125. </div>
  126. </div>
  127. <div v-if="!canPostPoll" class="form-group">
  128. <video ref="previewVideo" v-if="mediaType == 'video'" class="mb-4 w-100" style="max-height:200px;object-fit:contain;">
  129. <source :src="mediaUrl" type="video/mp4">
  130. </video>
  131. <label for="durationSlider" class="text-light lead font-weight-bold">Story Duration</label>
  132. <input type="range" class="custom-range" min="3" :max="max_duration" step="1" id="durationSlider" v-model="duration">
  133. <p class="help-text text-center">
  134. <span class="text-light">{{duration}} seconds</span>
  135. </p>
  136. </div>
  137. </div>
  138. <div class="flex-fill w-100 px-md-5">
  139. <div class="d-flex">
  140. <a class="btn btn-outline-muted btn-block font-weight-bold my-3 mr-3 rounded-pill" href="/" @click.prevent="deleteCurrentStory()">
  141. Cancel
  142. </a>
  143. <a class="btn btn-primary btn-block font-weight-bold my-3 rounded-pill" href="#" @click.prevent="shareStoryToFollowers()">
  144. Post {{ canPostPoll ? 'Poll' : 'Story'}}
  145. </a>
  146. </div>
  147. </div>
  148. </div>
  149. <div v-else-if="page == 'edit'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
  150. <div class="text-center flex-fill mt-5">
  151. <p class="text-muted font-weight-light mb-1">
  152. <i class="fal fa-history fa-5x"></i>
  153. </p>
  154. <p class="text-muted font-weight-bold mb-0">STORIES</p>
  155. </div>
  156. <div class="flex-fill py-4">
  157. <p class="lead font-weight-bold text-lighter">My Stories</p>
  158. <div class="card w-100 shadow-none bg-transparent" style="max-height: 50vh; overflow-y: scroll">
  159. <div class="list-group">
  160. <div v-for="(story, index) in stories" class="list-group-item bg-transparent text-center border-muted text-lighter" href="#">
  161. <div class="media align-items-center">
  162. <div class="mr-3 cursor-pointer" @click="showLightbox(story)">
  163. <img :src="story.src" class="rounded-circle border" width="40px" height="40px" style="object-fit: cover;">
  164. </div>
  165. <div class="media-body text-left">
  166. <p class="mb-0 text-muted font-weight-bold"><span>{{timeago(story.created_at)}} ago</span></p>
  167. </div>
  168. <div class="flex-grow-1 text-right">
  169. <button v-if="story.viewers.length" @click="toggleShowViewers(index)" class="btn btn-link btn-sm mr-1">
  170. <i class="fal fa-eye fa-lg text-muted"></i>
  171. </button>
  172. <button @click="deleteStory(story, index)" class="btn btn-link btn-sm">
  173. <i class="fal fa-trash-alt fa-lg text-muted"></i>
  174. </button>
  175. </div>
  176. </div>
  177. <div v-if="story.showViewers && story.viewers.length" class="m-2 text-left">
  178. <p class="font-weight-bold mb-2">Viewed By</p>
  179. <div v-for="viewer in story.viewers" class="d-flex">
  180. <img src="/storage/avatars/default.png" width="24" height="24" class="rounded-circle mr-2">
  181. <p class="mb-0 font-weight-bold">viewer.username</p>
  182. </div>
  183. </div>
  184. </div>
  185. </div>
  186. </div>
  187. </div>
  188. <div class="flex-fill text-center">
  189. <a class="btn btn-outline-secondary btn-block px-5 font-weight-bold" href="/i/stories/new" @click.prevent="goBack()">Go back</a>
  190. </div>
  191. </div>
  192. <div v-else-if="page == 'createPoll'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
  193. <div class="text-center pt-3">
  194. <img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
  195. <p class="lead text-lighter font-weight-light mb-0">Stories</p>
  196. </div>
  197. <div class="flex-fill mt-3">
  198. <div class="align-items-center">
  199. <div class="form-group mb-5">
  200. <label class="font-weight-bold text-lighter">Poll Question</label>
  201. <input class="form-control form-control-lg rounded-pill bg-muted shadow text-white border-0" placeholder="Ask a poll question here..." v-model="pollQuestion" />
  202. </div>
  203. <label class="font-weight-bold text-lighter">Poll Answers</label>
  204. <div v-for="(option, index) in pollOptions" class="form-group mb-4">
  205. <input class="form-control form-control-lg rounded-pill bg-muted shadow text-white border-0" placeholder="Add a poll answer here..." v-model="pollOptions[index]" />
  206. </div>
  207. <div v-if="pollOptions.length < 4" class="mb-3">
  208. <button
  209. class="btn btn-block font-weight-bold rounded-pill shadow"
  210. :class="[ (pollQuestion && pollQuestion.length) > 6 && (pollOptions.length == 0 || pollOptions.length && pollOptions[pollOptions.length - 1].length > 3) ? 'btn-muted' : 'btn-outline-muted' ]"
  211. :disabled="!pollQuestion || pollQuestion.length < 6"
  212. @click="addOptionInput">
  213. Add poll option
  214. </button>
  215. </div>
  216. <!-- <div v-for="(option, index) in pollOptions" class="form-group mb-4 d-flex align-items-center" style="max-width:400px;position: relative;">
  217. <span class="font-weight-bold mr-2" style="position: absolute;left: 10px;">{{ index + 1 }}.</span>
  218. <input v-if="pollOptions[index].length < 50" type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptions[index]" style="padding-left: 30px;padding-right: 90px;">
  219. <textarea v-else class="form-control" v-model="pollOptions[index]" placeholder="Add a poll option, press enter to save" rows="3" style="padding-left: 30px;padding-right:90px;"></textarea>
  220. <button class="btn btn-danger btn-sm rounded-pill font-weight-bold" style="position: absolute;right: 5px;" @click="deletePollOption(index)">
  221. <i class="fas fa-trash"></i> Delete
  222. </button>
  223. </div> -->
  224. </div>
  225. </div>
  226. <div class="flex-fill text-center">
  227. <a v-if="canPostPoll" class="btn btn-outline-light btn-block px-5 font-weight-bold rounded-pill" href="/i/stories/new" @click.prevent="pollPreview">Next</a>
  228. <a class="btn btn-outline-secondary btn-block px-5 font-weight-bold rounded-pill" href="/i/stories/new" @click.prevent="goBack()">Go back</a>
  229. </div>
  230. </div>
  231. </div>
  232. </div>
  233. <div v-else class="row">
  234. <div class="col-12 col-md-6 offset-md-3 bg-dark rounded-lg px-0" style="height: 90vh;">
  235. <div class="w-100 h-100 d-flex justify-content-center align-items-center">
  236. <div class="spinner-border text-lighter" role="status">
  237. <span class="sr-only">Loading...</span>
  238. </div>
  239. </div>
  240. </div>
  241. </div>
  242. <b-modal
  243. id="lightbox"
  244. ref="lightboxModal"
  245. hide-header
  246. hide-footer
  247. centered
  248. size="md"
  249. class="bg-transparent"
  250. body-class="p-0 bg-transparent"
  251. >
  252. <div v-if="lightboxMedia" class="w-100 h-100 bg-transparent">
  253. <img :src="lightboxMedia.url" style="max-height: 90vh; width: 100%; object-fit: contain;">
  254. </div>
  255. </b-modal>
  256. </div>
  257. </template>
  258. <script type="text/javascript">
  259. import VueTimeago from 'vue-timeago';
  260. import VueCropper from 'vue-cropperjs';
  261. import 'cropperjs/dist/cropper.css';
  262. export default {
  263. components: {
  264. VueCropper,
  265. VueTimeago
  266. },
  267. props: ['profile-id'],
  268. data() {
  269. return {
  270. loaded: false,
  271. config: window.App.config,
  272. mimes: [
  273. 'image/jpeg',
  274. 'image/png',
  275. 'video/mp4'
  276. ],
  277. page: 'landing',
  278. pages: [
  279. 'landing',
  280. 'crop',
  281. 'edit',
  282. 'confirm',
  283. 'error',
  284. 'uploading',
  285. 'createPoll'
  286. ],
  287. uploading: false,
  288. uploadProgress: 0,
  289. cropper: {
  290. aspectRatio: 9/16,
  291. viewMode: 3,
  292. zoomable: true,
  293. zoom: null
  294. },
  295. mediaUrl: null,
  296. mediaId: null,
  297. mediaType: null,
  298. stories: [],
  299. lightboxMedia: false,
  300. duration: 10,
  301. canReply: true,
  302. canReact: true,
  303. poll: {
  304. question: null,
  305. options: []
  306. },
  307. pollQuestion: null,
  308. pollOptions: [],
  309. canPostPoll: false,
  310. max_duration: 15
  311. };
  312. },
  313. watch: {
  314. duration: function(val) {
  315. if(this.mediaType == 'video') {
  316. this.$refs.previewVideo.currentTime = val;
  317. this.$refs.previewVideo.play();
  318. }
  319. },
  320. pollQuestion: function(val) {
  321. if(val.length < 6) {
  322. this.canPostPoll = false;
  323. }
  324. },
  325. pollOptions: function(val) {
  326. let len = this.pollOptions.filter(o => {
  327. return o.length >= 2;
  328. });
  329. if(len.length >= 2) {
  330. this.canPostPoll = true;
  331. } else {
  332. this.canPostPoll = false;
  333. }
  334. }
  335. },
  336. mounted() {
  337. $('body').addClass('bg-black');
  338. this.mediaWatcher();
  339. setTimeout(() => {
  340. axios.get('/api/web/stories/v1/profile/' + this.profileId)
  341. .then(res => {
  342. if(res.data.length) {
  343. this.stories = res.data[0].nodes.map(s => {
  344. s.showViewers = false;
  345. s.viewers = [];
  346. return s;
  347. });
  348. }
  349. this.loaded = true;
  350. });
  351. }, 400);
  352. },
  353. methods: {
  354. upload() {
  355. let fi = $('.file-input[name="media"]');
  356. fi.trigger('click');
  357. },
  358. mediaWatcher() {
  359. let self = this;
  360. $(document).on('change', '#pf-dz', function(e) {
  361. self.triggerUpload();
  362. });
  363. },
  364. triggerUpload() {
  365. let self = this;
  366. self.uploading = true;
  367. let io = document.querySelector('#pf-dz');
  368. self.page = 'uploading';
  369. Array.prototype.forEach.call(io.files, function(io, i) {
  370. if(self.media && self.media.length + i >= self.config.uploader.album_limit) {
  371. swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error');
  372. self.uploading = false;
  373. self.page = 2;
  374. return;
  375. }
  376. let type = io.type;
  377. let validated = $.inArray(type, self.mimes);
  378. if(validated == -1) {
  379. swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.mimes+' only.', 'error');
  380. self.uploading = false;
  381. self.page = 'error';
  382. return;
  383. }
  384. let form = new FormData();
  385. form.append('file', io);
  386. let xhrConfig = {
  387. onUploadProgress: function(e) {
  388. let progress = Math.floor( (e.loaded * 100) / e.total );
  389. self.uploadProgress = progress;
  390. }
  391. };
  392. io.value = null;
  393. axios.post('/api/web/stories/v1/add', form, xhrConfig)
  394. .then(function(e) {
  395. self.uploadProgress = 100;
  396. self.uploading = false;
  397. self.mediaUrl = e.data.media_url;
  398. self.mediaId = e.data.media_id;
  399. self.mediaType = e.data.media_type;
  400. self.page = e.data.media_type === 'video' ? 'preview' : 'crop';
  401. if(e.data.hasOwnProperty('media_duration')) {
  402. self.max_duration = e.data.media_duration;
  403. }
  404. // window.location.href = '/i/my/story';
  405. }).catch(function(e) {
  406. self.uploading = false;
  407. io.value = null;
  408. let msg = e.response.data.message ? e.response.data.message : e.response.data.error ? e.response.data.error :'Something went wrong.'
  409. swal('Oops!', msg, 'warning');
  410. self.page = 'error';
  411. });
  412. self.uploadProgress = 0;
  413. });
  414. document.querySelector('#pf-dz').value = '';
  415. },
  416. expiresTimestamp(ts) {
  417. ts = new Date(ts * 1000);
  418. return ts.toDateString() + ' ' + ts.toLocaleTimeString();
  419. },
  420. edit() {
  421. this.page = 'edit';
  422. },
  423. showLightbox(story) {
  424. this.lightboxMedia = {
  425. url: story.src
  426. }
  427. this.$refs.lightboxModal.show();
  428. },
  429. deleteStory(story, index) {
  430. if(window.confirm('Are you sure you want to delete this Story?') != true) {
  431. return;
  432. }
  433. axios.delete('/api/web/stories/v1/delete/' + story.id)
  434. .then(res => {
  435. this.stories.splice(index, 1);
  436. if(this.stories.length == 0) {
  437. window.location.href = '/i/stories/new';
  438. }
  439. });
  440. },
  441. navigateTo(path = '/') {
  442. window.location.href = path;
  443. },
  444. goBack() {
  445. this.page = 'landing';
  446. },
  447. performCrop() {
  448. this.page = 'cropping';
  449. let data = this.$refs.croppa.getData();
  450. axios.post('/api/web/stories/v1/crop', {
  451. media_id: this.mediaId,
  452. width: data.width,
  453. height: data.height,
  454. x: data.x,
  455. y: data.y
  456. }).then(res => {
  457. this.page = 'preview';
  458. });
  459. },
  460. deleteCurrentStory() {
  461. let story = {
  462. id: this.mediaId
  463. };
  464. this.deleteStory(story);
  465. this.page = 'landing';
  466. },
  467. shareStoryToFollowers() {
  468. if(this.canPostPoll) {
  469. axios.post('/api/web/stories/v1/publish/poll', {
  470. question: this.pollQuestion,
  471. options: this.pollOptions,
  472. can_reply: this.canReply,
  473. can_react: this.canReact
  474. }).then(res => {
  475. window.location.href = '/i/my/story?id=' + this.mediaId;
  476. })
  477. } else {
  478. axios.post('/api/web/stories/v1/publish', {
  479. media_id: this.mediaId,
  480. duration: this.duration,
  481. can_reply: this.canReply,
  482. can_react: this.canReact
  483. }).then(res => {
  484. window.location.href = '/i/my/story?id=' + this.mediaId;
  485. })
  486. }
  487. },
  488. viewMyStory() {
  489. window.location.href = '/i/my/story';
  490. },
  491. toggleShowViewers(index) {
  492. this.stories[index].showViewers = this.stories[index].showViewers ? false : true;
  493. },
  494. timeago(ts) {
  495. return App.util.format.timeAgo(ts);
  496. },
  497. newPoll() {
  498. this.page = 'createPoll';
  499. },
  500. addOptionInput() {
  501. let c = this.pollOptions.filter(o => {
  502. return o.length < 3;
  503. });
  504. if(c.length) {
  505. return;
  506. }
  507. this.pollOptions.push([]);
  508. },
  509. pollPreview() {
  510. let opts = this.pollOptions;
  511. let dd = [...new Set(this.pollOptions)];
  512. if(dd.length != opts.length) {
  513. swal('Oops!', 'You cannot use duplicate poll answers, please remove any duplicates and try again.', 'error');
  514. return;
  515. }
  516. this.page = 'preview';
  517. }
  518. }
  519. }
  520. </script>
  521. <style lang="scss">
  522. .bg-black {
  523. background-color: #262626;
  524. }
  525. </style>
  526. <style lang="scss" scoped>
  527. .story-compose-component {
  528. #lightbox .modal-content {
  529. background: transparent;
  530. }
  531. ::placeholder {
  532. color: #ccc;
  533. }
  534. .crop-container {
  535. z-index: 9;
  536. position: absolute;
  537. top: 0;
  538. width: 100%;
  539. min-height: 100px;
  540. padding: 15px 30px;
  541. background: linear-gradient(180deg, rgba(38,38,38, 0.8) 0%, rgba(38,38,38,0) 100%);
  542. }
  543. }
  544. </style>