pixelfed/resources/assets/js/components/CollectionComponent.vue

683 lines
20 KiB
Vue

<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>
&nbsp; &nbsp;
<button class="btn btn-outline-light btn-sm" @click.prevent="editCollection" onclick="this.blur();">Edit</button>
&nbsp; &nbsp;
<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 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;
});
},
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.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>