<template> <div class="w-100 h-100"> <div v-if="!loaded" style="height: 80vh;" class="d-flex justify-content-center align-items-center"> <img src="/img/pixelfed-icon-grey.svg" class=""> </div> <div class="row mt-3" v-if="loaded"> <div class="col-12 p-0 mb-3"> <div v-if="owner && !collection.published_at"> <div class="alert alert-danger d-flex justify-content-center"> <div class="media align-items-center"> <i class="far fa-exclamation-triangle fa-3x mr-3"></i> <div class="media-body"> <p class="font-weight-bold mb-0"> This collection is unpublished. </p> <p class="small mb-0"> This collection is not visible to anyone else until you publish it. <br /> To publish, click on the <strong>Edit</strong> button and then click on the <strong>Publish</strong> button. </p> </div> </div> </div> </div> </div> <div class="col-12 p-0 mb-3"> <div class="d-flex align-items-center justify-content-center overflow-hidden"> <div class="dims"></div> <div style="z-index:500;position: absolute;" class="text-white mx-5"> <p class="text-center pt-3 text-break" style="font-size: 3rem;line-height: 3rem;">{{title || 'Untitled Collection'}}</p> <div class="text-center mb-3 text-break read-more" style="overflow-y: hidden">{{description}}</div> <p class="text-center"> <span v-if="owner && collection.visibility != 'public'"> <span v-if="collection.visibility == 'draft'" class="btn btn-outline-light btn-sm text-capitalize py-0" style="font-size: 10px" > <i class="far fa-lock"></i> Draft </span> <span v-else-if="collection.visibility == 'private'" class="btn btn-outline-light btn-sm text-capitalize py-0" style="font-size: 10px" > Followers Only </span> <span>·</span> </span> <span>{{collection.post_count}} photos</span> <span>·</span> <span>by <a :href="'/' + profileUsername" class="font-weight-bold text-white">{{profileUsername}}</a></span> </p> <p v-if="owner == true" class="pt-3 text-center"> <span> <button class="btn btn-outline-light btn-sm" @click.prevent="addToCollection" onclick="this.blur();"> <span v-if="loadingPostList == false">Add Photo</span> <span v-else class="px-4"> <div class="spinner-border spinner-border-sm" role="status"> <span class="sr-only">Loading...</span> </div> </span> </button> <button class="btn btn-outline-light btn-sm" @click.prevent="editCollection" onclick="this.blur();">Edit</button> <button class="btn btn-outline-light btn-sm" @click.prevent="deleteCollection">Delete</button> </span> </p> </div> <img v-if="posts && posts.length" :src="previewUrl(posts[0])" alt="" style="width:100%; height: 400px; object-fit: cover;" > <div v-else class="bg-info" style="width:100%; height: 400px;"></div> </div> </div> <div class="col-12 p-0"> <!-- <masonry :cols="{default: 2, 700: 2, 400: 1}" :gutter="{default: '5px'}" > --> <div v-if="posts && posts.length > 0" class="row px-3 px-md-0"> <div v-for="(s, index) in posts" class="col-6 col-md-4 feed"> <!-- <a class="card info-overlay card-md-border-0 mb-4 square" :href="s.url"> <img :src="previewUrl(s)" class="square-content w-100" style="object-fit: cover;"> </a> --> <a v-if="s.hasOwnProperty('pf_type') && s.pf_type == 'video'" class="card info-overlay card-md-border-0" :href="statusUrl(s)"> <div class="square"> <div class="square-content"> <div class="info-overlay-text-label rounded"> <h5 class="text-white m-auto font-weight-bold"> <span> <span class="far fa-video fa-2x p-2 d-flex-inline"></span> </span> </h5> </div> <blur-hash-canvas width="32" height="32" class="rounded" :hash="s.media_attachments[0].blurhash"> </blur-hash-canvas> </div> </div> </a> <a v-else-if="s.sensitive" class="card info-overlay card-md-border-0" :href="statusUrl(s)"> <div class="square"> <div class="square-content"> <div class="info-overlay-text-label rounded"> <h5 class="text-white m-auto font-weight-bold"> <span> <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span> </span> </h5> </div> <blur-hash-canvas width="32" height="32" class="rounded" :hash="s.media_attachments[0].blurhash"> </blur-hash-canvas> </div> </div> </a> <a v-else class="card info-overlay card-md-border-0" :href="statusUrl(s)"> <div class="square"> <div class="square-content"> <!-- <img :src="previewUrl(s)" class="img-fluid w-100 rounded-lg" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0'"> <span class="badge badge-light" style="position: absolute;bottom:2px;right:2px;opacity: 0.4;"> {{ timeago(s.created_at) }} </span> --> <blur-hash-image width="32" height="32" class="rounded" :hash="s.media_attachments[0].blurhash" :src="previewUrl(s)" /> </div> </div> </a> </div> <div v-if="canLoadMore" class="col-12"> <intersect @enter="enterIntersect"> <div class="card card-body shadow-none border"> <div class="d-flex justify-content-center align-items-center flex-column"> <b-spinner variant="muted" /> <p class="text-lighter small mt-2 mb-0">Loading more...</p> </div> </div> </intersect> </div> </div> <!-- </masonry> --> </div> </div> <b-modal ref="editModal" id="edit-modal" hide-footer centered title="Edit Collection" body-class=""> <form> <div class="form-group"> <label for="title" class="font-weight-bold text-muted">Title</label> <input type="text" class="form-control" id="title" placeholder="Untitled Collection" v-model="title" maxlength="50"> <div class="text-right small text-muted"> <span>{{title ? title.length : 0}}/50</span> </div> </div> <div class="form-group"> <label for="description" class="font-weight-bold text-muted">Description</label> <textarea class="form-control" id="description" placeholder="Add a description here ..." v-model="description" rows="3" maxlength="500"></textarea> <div class="text-right small text-muted"> <span>{{description ? description.length : 0}}/500</span> </div> </div> <div class="form-group"> <label for="visibility" class="font-weight-bold text-muted">Visibility</label> <select class="custom-select" v-model="visibility"> <option value="public">Public</option> <option value="private">Followers Only</option> <option value="draft">Draft</option> </select> </div> <div class="d-flex justify-content-between align-items-center pt-3"> <a class="text-primary font-weight-bold text-decoration-none" href="#" @click.prevent="showEditPhotosModal"> Edit Photos </a> <div v-if="collection.published_at"> <button type="button" class="btn btn-primary btn-sm py-1 font-weight-bold px-3 float-right" @click.prevent="updateCollection"> Save </button> </div> <div v-else class="float-right"> <button v-if="posts.length > 0" type="button" class="btn btn-outline-primary btn-sm py-1 font-weight-bold px-3" @click.prevent="publishCollection"> Publish </button> <button v-else type="button" class="btn btn-outline-primary btn-sm py-1 font-weight-bold px-3 disabled" disabled> Publish </button> <button type="button" class="btn btn-primary btn-sm py-1 font-weight-bold px-3" @click.prevent="updateCollection"> Save </button> </div> </div> </form> </b-modal> <b-modal ref="addPhotoModal" id="add-photo-modal" hide-footer centered title="Add Photo" body-class="m-3"> <div class="form-group"> <label for="title" class="font-weight-bold text-muted">Add Recent Post</label> <div class="row m-1" v-if="postsList.length > 0" style="max-height: 360px; overflow-y: auto;"> <div v-for="(p, index) in postsList" :key="'postList-'+index" class="col-4 p-1 cursor-pointer" @click="addRecentId(p)"> <div class="square border"> <div class="square-content" v-bind:style="'background-image: url(' + getPreviewUrl(p) + ');'"></div> </div> </div> <div class="col-12"> <hr> </div> </div> </div> <form> <div class="form-group"> <label for="title" class="font-weight-bold text-muted">Add Post by URL</label> <input type="text" class="form-control" placeholder="https://pixelfed.dev/p/admin/1" v-model="photoId"> <p class="help-text small text-muted">Only local, public posts can be added</p> </div> <button type="button" class="btn btn-primary btn-sm py-1 font-weight-bold px-3 float-right" @click.prevent="pushId"> <span v-if="addingPostToCollection" class="px-4"> <div class="spinner-border spinner-border-sm" role="status"> <span class="sr-only">Loading...</span> </div> </span> <span v-else> Add Photo </span> </button> </form> </b-modal> <b-modal ref="editPhotosModal" id="edit-photos-modal" hide-footer centered title="Edit Collection Photos" body-class="m-3"> <div class="form-group"> <p class="font-weight-bold text-dark text-center">Select a Photo to Delete</p> <div class="row m-1 scrollbar-hidden" v-if="posts.length > 0" style="max-height: 350px;overflow-y: auto;"> <div v-for="(p, index) in posts" :key="'plm-'+index" class="col-4 p-1 cursor-pointer"> <div :class="[markedForDeletion.indexOf(p.id) == -1 ? 'square' : 'square delete-border']" @click="markPhotoForDeletion(p.id)"> <div class="square-content border" v-bind:style="'background-image: url(' + p.media_attachments[0].url + ');'"></div> </div> </div> </div> <div v-show="markedForDeletion.length > 0"> <button type="button" @click.prevent="confirmDeletion" class="btn btn-primary font-weight-bold py-0 btn-block mb-0 mt-4">Delete {{markedForDeletion.length}} {{markedForDeletion.length == 1 ? 'photo':'photos'}}</button> </div> </div> </b-modal> </div> </template> <style lang="scss" scoped> .dims { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0,0,0,.68); z-index: 300; } .scrollbar-hidden::-webkit-scrollbar { display: none; } .delete-border { border: 4px solid #ff0000; } .delete-border .square-content { background-color: red; background-blend-mode: screen; } .info-overlay-text-field { font-size: 13.5px; margin-bottom: 2px; @media (min-width: 768px) { font-size: 20px; margin-bottom: 15px; } } .feed { .card.info-overlay { margin-bottom: 2rem; } } </style> <script type="text/javascript"> import VueMasonry from 'vue-masonry-css'; import Intersect from 'vue-intersect'; export default { props: [ 'collection-id', 'collection-title', 'collection-description', 'collection-visibility', 'profile-id', 'profile-username' ], components: { "intersect": Intersect, }, data() { return { collection: {}, config: window.App.config, loaded: false, posts: [], ids: [], user: false, owner: false, title: this.collectionTitle, description: this.collectionDescription, visibility: this.collectionVisibility, photoId: '', postsList: [], loadingPostList: false, addingPostToCollection: false, markedForDeletion: [], canLoadMore: false, isIntersecting: false, page: 1 } }, beforeMount() { this.fetchCollection(); }, updated() { this.initReadMore(); }, methods: { enterIntersect() { if(this.isIntersecting) { return; } this.isIntersecting = true; this.page++; this.fetchItems(); }, statusUrl(s) { return '/i/web/post/' + s.id; }, fetchCollection() { axios.get('/api/local/collection/' + this.collectionId) .then(res => { this.collection = res.data; if(this.collection.post_count > 9) { this.canLoadMore = true; } this.fetchCurrentUser(); }) }, fetchCurrentUser() { if(document.querySelectorAll('body')[0].classList.contains('loggedIn') == true) { axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => { this.user = res.data; this.owner = this.user.id == this.profileId; window._sharedData.curUser = res.data; window.App.util.navatar(); this.fetchItems(); }); } else { this.fetchItems(); } }, fetchItems() { axios.get( '/api/local/collection/items/' + this.collectionId, { params: { page: this.page } } ) .then(res => { if(res.data.length == 0) { console.log('no items found'); this.loaded = true; this.isIntersecting = false; this.canLoadMore = false; return; } let data = res.data.filter(p => { return this.ids.indexOf(p.id) == -1; }); this.posts.push(...data); this.ids = this.posts.map(p => { return p.id; }); this.loaded = true; this.isIntersecting = false; if(data.length == 0) { this.canLoadMore = false; } }); }, previewUrl(status) { return status && status.sensitive ? '/storage/no-preview.png?v=' + new Date().getTime() : status.media_attachments[0].url; }, previewBackground(status) { let preview = this.previewUrl(status); return 'background-image: url(' + preview + ');'; }, addToCollection() { let self = this; this.loadingPostList = true; if(this.postsList.length == 0) { axios.get('/api/v1/accounts/'+this.profileId+'/statuses', { params: { min_id: 1, limit: 40 } }) .then(res => { self.postsList = res.data.filter(l => { return (l.visibility == 'public' || l.visibility == 'unlisted') && l.sensitive == false && self.ids.indexOf(l.id) == -1; }); self.loadingPostList = false; self.$refs.addPhotoModal.show(); }).catch(err => { self.loadingPostList = false; swal('An Error Occured', 'We cannot process your request at this time, please try again later.', 'error'); }) } else { this.$refs.addPhotoModal.show(); this.loadingPostList = false; } }, pushId() { let max = this.config.uploader.max_collection_length; let addingPostToCollection = true; let self = this; if(this.posts.length >= max) { swal('Error', 'You can only add ' + max + ' posts per collection', 'error'); return; } let url = this.photoId; let origin = window.location.origin; let split = url.split('/'); if(url.slice(0, origin.length) !== origin) { swal('Invalid URL', 'You can only add posts from this instance', 'error'); this.photoId = ''; } if(!url.includes('/i/web/post/') && !url.includes('/p/')) { swal('Invalid URL', 'Invalid URL', 'error'); this.photoId = ''; return; } let fragment = split[split.length - 1].split('?')[0]; axios.post('/api/local/collection/item', { collection_id: this.collectionId, post_id: fragment }).then(res => { self.ids.push(...fragment); self.posts.push(res.data); self.collection.post_count++; self.id = ''; }).catch(err => { swal('Invalid URL', 'The post you entered was invalid', 'error'); this.photoId = ''; }); self.$refs.addPhotoModal.hide(); // window.location.reload(); }, editCollection() { this.$refs.editModal.show(); }, deleteCollection() { if(this.owner == false) { return; } let confirmed = window.confirm('Are you sure you want to delete this collection?'); if(confirmed) { axios.delete('/api/local/collection/' + this.collectionId) .then(res => { window.location.href = '/'; }); } else { return; } }, publishCollection() { if (this.posts.length === 0) { swal('Error', 'You cannot publish an empty collection'); return; } if(this.owner == false) { return; } let confirmed = window.confirm('Are you sure you want to publish this collection?'); if(confirmed) { axios.post('/api/local/collection/' + this.collectionId + '/publish', { title: this.title, description: this.description, visibility: this.visibility }) .then(res => { console.log(res.data); // window.location.href = res.data.url; }).catch(err => { swal('Something went wrong', 'There was a problem with your request, please try again later.', 'error') }); } else { return; } }, updateCollection() { this.closeModals(); axios.post('/api/local/collection/' + this.collectionId, { title: this.title, description: this.description, visibility: this.visibility }).then(res => { this.collection = res.data; }); }, showEditPhotosModal() { this.$refs.editModal.hide(); this.$refs.editPhotosModal.show(); }, markPhotoForDeletion(id) { this.markedForDeletion.indexOf(id) == -1 ? this.markedForDeletion.push(id) : this.markedForDeletion = this.markedForDeletion.filter(d => { return d != id; }); }, confirmDeletion() { let self = this; let confirmed = window.confirm('Are you sure you want to delete this?'); if(confirmed) { this.markedForDeletion.forEach(mfd => { axios.delete('/api/local/collection/item', { params: { collection_id: self.collectionId, post_id: mfd } }) .then(res => { self.removeItem(mfd); this.collection.post_count = this.collection.post_count - 1; this.closeModals(); }) .catch(err => { swal( 'Oops!', 'An error occured with your request, please try again later.', 'error' ); }) }); this.markedForDeletion = []; } }, removeItem(id) { this.posts = this.posts.filter(post => { return post.id != id; }); this.ids = this.ids.filter(post_id => { return post_id != id; }); }, addRecentId(post) { let self = this; axios.post('/api/local/collection/item', { collection_id: self.collectionId, post_id: post.id }).then(res => { // window.location.reload(); this.closeModals(); this.posts.push(res.data); this.ids.push(post.id); this.collection.post_count++; }).catch(err => { swal('Oops!', 'An error occured, please try selecting another post.', 'error'); this.photoId = ''; }); }, timeago(ts) { return App.util.format.timeAgo(ts); }, closeModals() { this.$refs.editModal.hide(); this.$refs.addPhotoModal.hide(); this.$refs.editPhotosModal.hide(); }, getPreviewUrl(post) { if(!post.media_attachments || !post.media_attachments.length) { return '/storage/no-preview.png'; } let media = post.media_attachments[0]; if(media.preview_url.endsWith('storage/no-preview.png')) { return media.type === 'image' ? media.url : '/storage/no-preview.png'; } return media.preview_url; }, initReadMore() { $('.read-more').each(function(k,v) { let el = $(this); let attr = el.attr('data-readmore'); if(typeof attr !== typeof undefined && attr !== false) { return; } el.readmore({ collapsedHeight: 38, heightMargin: 38, moreLink: '<a href="#" class="d-block text-center small font-weight-bold mt-n3 mb-2" style="color: rgba(255, 255, 255, 0.5)">Show more</a>', lessLink: '<a href="#" class="d-block text-center small font-weight-bold mt-n3 mb-2" style="color: rgba(255, 255, 255, 0.5)">Show less</a>', }); }); } } } </script>