ComposeUI update

This commit is contained in:
Daniel Supernault 2019-08-22 22:33:04 -06:00
parent 06078c7a85
commit 89ca18b14e
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
6 changed files with 549 additions and 10 deletions

View file

@ -68,6 +68,8 @@ class AdminApiController extends Controller
public function moderateStatus(Request $request)
{
abort(400, 'Unpublished API');
return;
$this->validate($request, [
'type' => 'required|string|in:status,profile',
'id' => 'required|integer|min:1',

View file

@ -0,0 +1,523 @@
<template>
<div>
<input type="file" name="media" class="d-none file-input" multiple="" v-bind:accept="config.uploader.media_types">
<div class="timeline">
<div class="card status-card card-md-rounded-0">
<div class="card-header d-inline-flex align-items-center bg-white">
<img v-bind:src="profile.avatar" width="32px" height="32px" style="border-radius: 32px;" class="box-shadow">
<a class="username font-weight-bold pl-2 text-dark" v-bind:href="profile.url">
{{profile.username}}
</a>
<div class="text-right" style="flex-grow:1;">
<div class="dropdown">
<button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
<span class="fas fa-ellipsis-v fa-lg text-muted"></span>
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
<div class="dropdown-item small font-weight-bold" v-on:click="createCollection">Create Collection</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item small font-weight-bold" v-on:click="about">About</div>
<div class="dropdown-item small font-weight-bold" v-on:click="closeModal">Close</div>
</div>
</div>
</div>
</div>
<div class="postPresenterContainer">
<div v-if="uploading">
<div class="w-100 h-100 bg-light py-5" style="border-bottom: 1px solid #f1f1f1">
<div class="p-5">
<b-progress :value="uploadProgress" :max="100" striped :animated="true"></b-progress>
<p class="text-center mb-0 font-weight-bold">Uploading ... ({{uploadProgress}}%)</p>
</div>
</div>
</div>
<div v-else>
<div v-if="ids.length > 0 && ids.length != config.uploader.album_limit" class="card-header py-2 bg-primary m-2 rounded cursor-pointer" v-on:click="addMedia($event)">
<p class="text-center mb-0 font-weight-bold text-white"><i class="fas fa-plus mr-1"></i> Add Photo</p>
</div>
<div v-if="ids.length == 0" class="w-100 h-100 bg-light py-5 cursor-pointer" style="border-bottom: 1px solid #f1f1f1" v-on:click="addMedia($event)">
<div class="p-5">
<p class="text-center font-weight-bold">{{composeMessage()}}</p>
<p class="text-muted mb-0 small text-center">Accepted Formats: <b>{{acceptedFormats()}}</b></p>
<p class="text-muted mb-0 small text-center">Max File Size: <b>{{maxSize()}}</b></p>
<p class="text-muted mb-0 small text-center">Albums can contain up to <b>{{config.uploader.album_limit}}</b> photos or videos</p>
</div>
</div>
<div v-if="ids.length > 0">
<b-carousel id="p-carousel"
style="text-shadow: 1px 1px 2px #333;"
controls
indicators
background="#ffffff"
:interval="0"
v-model="carouselCursor"
>
<b-carousel-slide v-if="ids.length > 0" v-for="(preview, index) in media" :key="'preview_media_'+index">
<div slot="img" :class="[media[index].filter_class?media[index].filter_class:'']" style="display:flex;min-height: 320px;align-items: center;">
<img class="d-block img-fluid w-100" :src="preview.url" :alt="preview.description" :title="preview.description">
</div>
</b-carousel-slide>
</b-carousel>
</div>
<div v-if="ids.length > 0 && media[carouselCursor].type == 'Image'" class="bg-dark align-items-center">
<ul class="nav media-drawer-filters text-center">
<li class="nav-item">
<div class="p-1 pt-3">
<img :src="media[carouselCursor].url" width="100px" height="60px" v-on:click.prevent="toggleFilter($event, null)" class="cursor-pointer">
</div>
<a :class="[media[carouselCursor].filter_class == null ? 'nav-link text-white active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, null)">No Filter</a>
</li>
<li class="nav-item" v-for="(filter, index) in filters">
<div class="p-1 pt-3">
<img :src="media[carouselCursor].url" width="100px" height="60px" :class="filter[1]" v-on:click.prevent="toggleFilter($event, filter[1])">
</div>
<a :class="[media[carouselCursor].filter_class == filter[1] ? 'nav-link text-white active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, filter[1])">{{filter[0]}}</a>
</li>
</ul>
</div>
</div>
<div v-if="ids.length > 0 && ['Image', 'Video'].indexOf(media[carouselCursor].type) != -1" class="bg-lighter p-2 row">
<div v-if="media[carouselCursor].type == 'Image'" class="col-12">
<div class="form-group">
<input type="text" class="form-control" v-model="media[carouselCursor].alt" placeholder="Optional image description">
</div>
<div class="form-group">
<input type="text" class="form-control" v-model="media[carouselCursor].license" placeholder="Optional media license">
</div>
</div>
<!-- <div class="col-6 pt-2">
<button class="btn btn-outline-secondary btn-sm mr-1"><i class="fas fa-map-marker-alt"></i></button>
<button class="btn btn-outline-secondary btn-sm"><i class="fas fa-tools"></i></button>
</div> -->
<div class="col-12 text-right pt-2">
<button class="btn btn-outline-danger btn-sm font-weight-bold mr-1" v-on:click="deleteMedia()">Delete Media</button>
</div>
</div>
</div>
<div class="card-body p-0 border-top">
<div class="caption">
<textarea class="form-control mb-0 border-0 rounded-0" rows="3" placeholder="Add an optional caption" v-model="composeText"></textarea>
</div>
</div>
<div class="card-footer">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="custom-control custom-switch d-inline mr-3">
<input type="checkbox" class="custom-control-input" id="nsfwToggle" v-model="nsfw">
<label class="custom-control-label small font-weight-bold text-muted pt-1" for="nsfwToggle">NSFW</label>
</div>
<div class="dropdown d-inline">
<button class="btn btn-outline-secondary btn-sm py-0 dropdown-toggle" type="button" id="visibility" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{visibility[0].toUpperCase() + visibility.slice(1)}}
</button>
<div class="dropdown-menu" aria-labelledby="visibility" style="width: 200px;">
<a :class="[visibility=='public'?'dropdown-item active':'dropdown-item']" href="#" data-id="public" data-title="Public" v-on:click.prevent="visibility = 'public'">
<div class="row">
<div class="d-none d-block-sm col-sm-2 px-0 text-center">
<i class="fas fa-globe"></i>
</div>
<div class="col-12 col-sm-10 pl-2">
<p class="font-weight-bold mb-0">Public</p>
<p class="small mb-0">Anyone can see</p>
</div>
</div>
</a>
<a :class="[visibility=='private'?'dropdown-item active':'dropdown-item']" href="#" data-id="private" data-title="Followers Only" v-on:click.prevent="visibility = 'private'">
<div class="row">
<div class="d-none d-block-sm col-sm-2 px-0 text-center">
<i class="fas fa-lock"></i>
</div>
<div class="col-12 col-sm-10 pl-2">
<p class="font-weight-bold mb-0">Followers Only</p>
<p class="small mb-0">Only followers can see</p>
</div>
</div>
</a>
<a :class="[visibility=='unlisted'?'dropdown-item active':'dropdown-item']" href="#" data-id="private" data-title="Unlisted" v-on:click.prevent="visibility = 'unlisted'">
<div class="row">
<div class="d-none d-block-sm col-sm-2 px-0 text-center">
<i class="fas fa-lock"></i>
</div>
<div class="col-12 col-sm-10 pl-2">
<p class="font-weight-bold mb-0">Unlisted</p>
<p class="small mb-0">Not listed on public timelines</p>
</div>
</div>
</a>
<!-- <a class="dropdown-item" href="#" data-id="circle" data-title="Circle">
<div class="row">
<div class="col-12 col-sm-2 px-0 text-center">
<i class="far fa-circle"></i>
</div>
<div class="col-12 col-sm-10 pl-2">
<p class="font-weight-bold mb-0">Circle</p>
<p class="small mb-0">Select a circle</p>
</div>
</div>
</a>
<a class="dropdown-item" href="#" data-id="direct" data-title="Direct Message">
<div class="row">
<div class="col-12 col-sm-2 px-0 text-center">
<i class="fas fa-envelope"></i>
</div>
<div class="col-12 col-sm-10 pl-2">
<p class="font-weight-bold mb-0">Direct Message</p>
<p class="small mb-0">Recipients only</p>
</div>
</div>
</a> -->
</div>
</div>
</div>
<div class="small text-muted font-weight-bold">
{{composeText.length}} / {{config.uploader.max_caption_length}}
</div>
<div class="pl-md-5">
<!-- <div class="btn-group">
<button type="button" class="btn btn-primary btn-sm font-weight-bold" v-on:click="compose()">{{composeState[0].toUpperCase() + composeState.slice(1)}}</button>
<button type="button" class="btn btn-primary btn-sm dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a :class="[composeState == 'publish' ?'dropdown-item font-weight-bold active':'dropdown-item font-weight-bold ']" href="#" v-on:click.prevent="composeState = 'publish'">Publish now</a>
<!- - <a :class="[composeState == 'draft' ?'dropdown-item font-weight-bold active':'dropdown-item font-weight-bold ']" href="#" v-on:click.prevent="composeState = 'draft'">Save as draft</a>
<a :class="[composeState == 'schedule' ?'dropdown-item font-weight-bold active':'dropdown-item font-weight-bold ']" href="#" v-on:click.prevent="composeState = 'schedule'">Schedule for later</a>
<div class="dropdown-divider"></div>
<a :class="[composeState == 'delete' ?'dropdown-item font-weight-bold active':'dropdown-item font-weight-bold ']" href="#" v-on:click.prevent="composeState = 'delete'">Delete</a> - ->
</div>
</div> -->
<button class="btn btn-primary btn-sm font-weight-bold px-3" v-on:click="compose()">Publish</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style type="text/css" scoped>
.media-drawer-filters {
overflow-x: scroll;
flex-wrap:unset;
}
.media-drawer-filters .nav-link {
min-width:100px;
padding-top: 1rem;
padding-bottom: 1rem;
}
.media-drawer-filters .active {
color: #fff;
font-weight: bold;
}
@media (hover: none) and (pointer: coarse) {
.media-drawer-filters::-webkit-scrollbar {
display: none;
}
}
</style>
<script type="text/javascript">
export default {
data() {
return {
config: window.App.config,
profile: {},
composeText: '',
composeTextLength: 0,
nsfw: false,
filters: [],
ids: [],
media: [],
carouselCursor: 0,
visibility: 'public',
mediaDrawer: false,
composeState: 'publish',
uploading: false,
uploadProgress: 0,
composeType: false
}
},
beforeMount() {
this.fetchProfile();
},
mounted() {
this.mediaWatcher();
this.filters = [
['1977','filter-1977'],
['Aden','filter-aden'],
['Amaro','filter-amaro'],
['Ashby','filter-ashby'],
['Brannan','filter-brannan'],
['Brooklyn','filter-brooklyn'],
['Charmes','filter-charmes'],
['Clarendon','filter-clarendon'],
['Crema','filter-crema'],
['Dogpatch','filter-dogpatch'],
['Earlybird','filter-earlybird'],
['Gingham','filter-gingham'],
['Ginza','filter-ginza'],
['Hefe','filter-hefe'],
['Helena','filter-helena'],
['Hudson','filter-hudson'],
['Inkwell','filter-inkwell'],
['Kelvin','filter-kelvin'],
['Kuno','filter-juno'],
['Lark','filter-lark'],
['Lo-Fi','filter-lofi'],
['Ludwig','filter-ludwig'],
['Maven','filter-maven'],
['Mayfair','filter-mayfair'],
['Moon','filter-moon'],
['Nashville','filter-nashville'],
['Perpetua','filter-perpetua'],
['Poprocket','filter-poprocket'],
['Reyes','filter-reyes'],
['Rise','filter-rise'],
['Sierra','filter-sierra'],
['Skyline','filter-skyline'],
['Slumber','filter-slumber'],
['Stinson','filter-stinson'],
['Sutro','filter-sutro'],
['Toaster','filter-toaster'],
['Valencia','filter-valencia'],
['Vesper','filter-vesper'],
['Walden','filter-walden'],
['Willow','filter-willow'],
['X-Pro II','filter-xpro-ii']
];
},
methods: {
fetchProfile() {
axios.get('/api/v1/accounts/verify_credentials').then(res => {
this.profile = res.data;
if(res.data.locked == true) {
this.visibility = 'private';
}
}).catch(err => {
});
},
addMedia(event) {
let el = $(event.target);
el.attr('disabled', '');
let fi = $('.file-input[name="media"]');
fi.trigger('click');
el.blur();
el.removeAttr('disabled');
},
mediaWatcher() {
let self = this;
$(document).on('change', '.file-input', function(e) {
let io = document.querySelector('.file-input');
Array.prototype.forEach.call(io.files, function(io, i) {
self.uploading = true;
if(self.media && self.media.length + i >= self.config.uploader.album_limit) {
swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error');
return;
}
let type = io.type;
let acceptedMimes = self.config.uploader.media_types.split(',');
let validated = $.inArray(type, acceptedMimes);
if(validated == -1) {
swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.config.uploader.media_types+' only.', 'error');
return;
}
let form = new FormData();
form.append('file', io);
let xhrConfig = {
onUploadProgress: function(e) {
let progress = Math.round( (e.loaded * 100) / e.total );
self.uploadProgress = progress;
}
};
axios.post('/api/v1/media', form, xhrConfig)
.then(function(e) {
self.uploadProgress = 100;
self.ids.push(e.data.id);
self.media.push(e.data);
setTimeout(function() {
self.uploading = false;
}, 1000);
}).catch(function(e) {
self.uploading = false;
io.value = null;
swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error');
});
io.value = null;
self.uploadProgress = 0;
});
});
},
toggleFilter(e, filter) {
this.media[this.carouselCursor].filter_class = filter;
},
updateMedia() {
this.mediaDrawer = false;
},
deleteMedia() {
if(window.confirm('Are you sure you want to delete this media?') == false) {
return;
}
let id = this.media[this.carouselCursor].id;
axios.delete('/api/v1/media', {
params: {
id: id
}
}).then(res => {
if(this.media.length == 1) {
this.mediaDrawer = false;
this.ids = [];
this.media = [];
this.carouselCursor = 0;
}
this.ids.splice(this.carouselCursor, 1);
this.media.splice(this.carouselCursor, 1);
}).catch(err => {
swal('Whoops!', 'An error occured when attempting to delete this, please try again', 'error');
});
},
mediaAltText() {
return;
// deprecate
swal({
text: 'Add a media description',
content: "input"
}).then(val => {
let media = this.media[this.carouselCursor];
media.alt = val;
});
},
mediaLicense() {
return;
// deprecate
swal({
text: 'Add a media license',
content: "input",
button: {
text: "Update",
closeModal: true,
},
}).then(val => {
let media = this.media[this.carouselCursor];
media.license = val;
});
},
compose() {
let state = this.composeState;
if(this.uploadProgress != 100 || this.ids.length == 0) {
return;
}
if(this.composeText.length > this.config.uploader.max_caption_length) {
swal('Error', 'Caption is too long', 'error');
return;
}
switch(state) {
case 'publish' :
if(this.media.length == 0) {
swal('Whoops!', 'You need to add media before you can save this!', 'warning');
return;
}
if(this.composeText == 'Add optional caption...') {
this.composeText = '';
}
let data = {
media: this.media,
caption: this.composeText,
visibility: this.visibility,
cw: this.nsfw
};
axios.post('/api/local/status/compose', data)
.then(res => {
let data = res.data;
window.location.href = data;
}).catch(err => {
let res = err.response.data;
if(res.message == 'Too Many Attempts.') {
swal('You\'re posting too much!', 'We only allow 50 posts per hour or 100 per day. If you\'ve reached that limit, please try again later. If you think this is an error, please contact an administrator.', 'error');
return;
}
swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error');
});
return;
break;
case 'delete' :
this.mediaDrawer = false;
this.ids = [];
this.media = [];
this.carouselCursor = 0;
this.composeText = '';
this.composeTextLength = 0;
$('#composeModal').modal('hide');
return;
break;
}
},
about() {
let text = document.createElement('div');
text.innerHTML = `
<p class="small font-weight-bold">Please visit the <a href="/site/kb/sharing-media">Sharing Media</a> page for more info.</p>
`;
swal({
title: 'Compose UI v3',
content: text,
icon: 'info'
});
},
closeModal() {
this.composeType = '';
$('#composeModal').modal('hide');
},
composeMessage() {
let config = this.config;
let composeType = this.composeType;
let video = config.uploader.media_types.includes('video/mp4');
return video ?
'Click here to add photos or videos' :
'Click here to add photos';
},
createCollection() {
window.location.href = '/i/collections/create';
},
maxSize() {
let limit = this.config.uploader.max_photo_size;
return limit / 1000 + ' MB';
},
acceptedFormats() {
let formats = this.config.uploader.media_types;
return formats.split(',').map(f => {
return ' ' + f.split('/')[1];
}).toString();
}
}
}
</script>

View file

@ -0,0 +1,4 @@
Vue.component(
'compose-classic',
require('./components/ComposeClassic.vue').default
);

View file

@ -2,26 +2,31 @@
@section('content')
<div class="container mt-5">
{{-- <div class="container mt-5">
<div class="row">
<div class="col-12 col-md-6 offset-md-3">
<p class="lead text-center font-weight-bold">The Classic Compose UI has been retired.</p>
<p class="lead text-center font-weight-bold">Compose New Post</p>
<p class="lead text-center">
<a href="javascript:void(0)" class="btn btn-primary font-weight-bold" data-toggle="modal" data-target="#composeModal">New Post</a>
</p>
</div>
</div>
</div> --}}
<div class="modal pr-0" tabindex="-1" role="dialog" id="composeModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<compose-classic></compose-classic>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/compose-classic.js') }}"></script>
<script type="text/javascript">App.boot();</script>
<script type="text/javascript">
$(document).ready(function() {
new Vue({
el: '#content'
});
});
$('#composeModal').modal('show');
</script>
@endpush

View file

@ -102,13 +102,12 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('status/{id}/replies', 'InternalApiController@statusReplies');
Route::post('moderator/action', 'InternalApiController@modAction');
Route::get('discover/categories', 'InternalApiController@discoverCategories');
Route::post('status/compose', 'InternalApiController@composePost')->middleware('throttle:maxPostsPerHour,60')->middleware('throttle:maxPostsPerDay,1440');
Route::get('loops', 'DiscoverController@loopsApi');
Route::post('loops/watch', 'DiscoverController@loopWatch');
Route::get('discover/tag', 'DiscoverController@getHashtags');
});
Route::group(['prefix' => 'local'], function () {
Route::post('status/compose', 'InternalApiController@compose')->middleware('throttle:maxPostsPerHour,60')->middleware('throttle:maxPostsPerDay,1440');
Route::post('status/compose', 'InternalApiController@composePost')->middleware('throttle:maxPostsPerHour,60')->middleware('throttle:maxPostsPerDay,1440');
Route::get('exp/rec', 'ApiController@userRecommendations');
Route::post('discover/tag/subscribe', 'HashtagFollowController@store')->middleware('throttle:maxHashtagFollowsPerHour,60')->middleware('throttle:maxHashtagFollowsPerDay,1440');;
Route::get('discover/tag/list', 'HashtagFollowController@getTags');
@ -122,6 +121,10 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::post('collection/{id}/publish', 'CollectionController@publish')->middleware('throttle:maxCollectionsPerHour,60')->middleware('throttle:maxCollectionsPerDay,1440')->middleware('throttle:maxCollectionsPerMonth,43800');
Route::get('profile/collections/{id}', 'CollectionController@getUserCollections');
});
Route::group(['prefix' => 'admin'], function () {
Route::post('moderate', 'Api\AdminApiController@moderate');
});
});
Route::get('discover/tags/{hashtag}', 'DiscoverController@showTags');

2
webpack.mix.js vendored
View file

@ -21,6 +21,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
.js('resources/assets/js/status.js', 'public/js')
.js('resources/assets/js/timeline.js', 'public/js')
.js('resources/assets/js/compose.js', 'public/js')
.js('resources/assets/js/compose-classic.js', 'public/js')
.js('resources/assets/js/search.js', 'public/js')
.js('resources/assets/js/developers.js', 'public/js')
.js('resources/assets/js/loops.js', 'public/js')
@ -33,6 +34,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
.js('resources/assets/js/hashtag.js', 'public/js')
.js('resources/assets/js/collectioncompose.js', 'public/js')
.js('resources/assets/js/collections.js', 'public/js')
//.js('resources/assets/js/admin.js', 'public/js')
.extract([
'lodash',