ソースを参照

Add story components

Daniel Supernault 5 ヶ月 前
コミット
f82dfe8b3e

+ 314 - 143
resources/assets/js/components/StoryCompose.vue

@@ -1,154 +1,167 @@
 <template>
-<div class="container mt-2 mt-md-5 bg-black">
+<div class="story-compose-component container mt-2 mt-md-5 bg-black">
 	<input type="file" id="pf-dz" name="media" class="d-none file-input" v-bind:accept="config.mimes">
 	<span class="fixed-top text-right m-3 cursor-pointer" @click="navigateTo()">
-		<i class="fas fa-times fa-lg text-white"></i>
+		<i class="fal fa-times-circle fa-2x text-lighter"></i>
 	</span>
 	<div v-if="loaded" class="row">
-		<div class="col-12 col-md-6 offset-md-3 bg-dark rounded-lg">
+		<div class="col-12 col-md-6 offset-md-3 bg-dark rounded-lg px-0">
 
 			<!-- LANDING -->
 			<div v-if="page == 'landing'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
 				<div class="text-center flex-fill pt-3">
-					<p class="text-muted font-weight-light mb-1">
-						<i class="fas fa-history fa-5x"></i>
-					</p>
-					<p class="text-muted font-weight-bold mb-0">STORIES</p>
+					<img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
+					<p class="lead text-lighter font-weight-light mb-0">Stories</p>
 				</div>
 				<div class="flex-fill py-4">
+					<p class="text-center lead font-weight-light text-lighter mb-4">Share moments with followers that last 24 hours</p>
 					<div class="card w-100 shadow-none bg-transparent">
-						<div class="list-group bg-transparent">
-							<!-- <a class="list-group-item text-center lead text-decoration-none text-dark" href="#">Camera</a> -->
-							<a class="list-group-item bg-transparent lead text-decoration-none text-light font-weight-bold border-light" href="#" @click.prevent="upload()">
-								<i class="fas fa-plus-square mr-2"></i>
+						<div class="d-flex">
+							<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()">
 								Add to Story
-							</a>
-							<a v-if="stories.length" class="list-group-item bg-transparent lead text-decoration-none text-lighter font-weight-bold border-muted" href="#" @click.prevent="edit()">
-								<i class="far fa-clone mr-2"></i>
-								My Story
-							</a>
-							<a v-if="stories.length" class="list-group-item bg-transparent lead text-decoration-none text-lighter font-weight-bold border-muted" href="#" @click.prevent="viewMyStory()">
-								<i class="fas fa-history mr-2"></i>
-								View My Story
-							</a>
-							<!-- <a v-if="stories.length" class="list-group-item bg-transparent lead text-decoration-none text-lighter font-weight-bold border-muted" href="#" @click.prevent="edit()">
-								<i class="fas fa-network-wired mr-1"></i>
-								Audience
-							</a> -->
-							<!-- <a v-if="stories.length" class="list-group-item bg-transparent lead text-decoration-none text-lighter font-weight-bold border-muted" href="#" @click.prevent="edit()">
-								<i class="far fa-chart-bar mr-2"></i>
-								Stats
-							</a> -->
-							<!-- <a class="list-group-item bg-transparent lead text-decoration-none text-lighter font-weight-bold border-muted" href="#" @click.prevent="edit()">
-								<i class="far fa-folder mr-2"></i>
-								Archived
-							</a> -->
-							<!-- <a class="list-group-item bg-transparent lead text-decoration-none text-lighter font-weight-bold border-muted" href="#" @click.prevent="edit()">
-								<i class="far fa-question-circle mr-2"></i>
-								Help
-							</a> -->
-							<a class="list-group-item bg-transparent lead text-decoration-none text-lighter font-weight-bold border-muted" href="/">
-								<i class="fas fa-arrow-left mr-2"></i>
-								Go back
-							</a>
-							<!-- <a class="list-group-item text-center lead text-decoration-none text-dark" href="#">Options</a> -->
+							</button>
+							<!-- <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">
+								Create Poll
+							</button> -->
 						</div>
+						<p
+							v-if="stories.length >= 20"
+							class="font-weight-bold text-muted text-center">
+							You have reached the limit for new stories
+						</p>
+
+						<button
+							type="button"
+							class="btn btn-outline-light btn-lg font-weight-bold btn-block rounded-pill my-3"
+							@click="viewMyStory"
+							:disabled="stories.length == 0">
+							<span>My Story</span>
+							<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>
+						</button>
+
 					</div>
 				</div>
 				<div class="text-center flex-fill">
-					<!-- <p class="text-lighter small text-uppercase">
-						<a href="/" class="text-muted font-weight-bold">Home</a>
+					<p class="text-uppercase mb-0">
+						<a href="/" class="text-lighter font-weight-bold">Home</a>
 						<span class="px-2 text-lighter">|</span>
-						<a href="/i/my/story" class="text-muted font-weight-bold">View My Story</a>
-						<span class="px-2 text-lighter">|</span>
-						<a href="/site/help" class="text-muted font-weight-bold">Help</a>
-					</p> -->
+						<a href="/site/help" class="text-lighter font-weight-bold">Help</a>
+					</p>
+					<p class="small text-muted mb-0">v 1.0.0</p>
 				</div>
 			</div>
 
-			<!-- CROP -->
-			<div v-if="page == 'crop'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
-				<div class="text-center py-3 d-flex justify-content-between align-items-center">
-					<div>
-						<button class="btn btn-outline-lighter btn-sm py-1 px-md-3" @click="deleteCurrentStory()"><i class="pr-2 fas fa-chevron-left fa-sm"></i> Delete</button>
-					</div>
-					<div class="">
-						<p class="text-muted font-weight-light mb-1">
-							<i class="fas fa-history fa-5x"></i>
-						</p>
-						<p class="text-muted font-weight-bold mb-0">STORIES</p>
-					</div>
-					<div>
-						<button class="btn btn-primary btn-sm py-1 px-md-3" @click="performCrop()">Crop <i class="pl-2 fas fa-chevron-right fa-sm"></i></button>
-					</div>
-				</div>
-				<div class="flex-fill">
-					<div class="card w-100 mt-3">
-						<div class="card-body p-0">
-							<vue-cropper
-								ref="croppa"
-								:relativeZoom="cropper.zoom"
-								:aspectRatio="cropper.aspectRatio"
-								:viewMode="cropper.viewMode"
-								:zoomable="cropper.zoomable"
-								:rotatable="true"
-								:src="mediaUrl"
-							>
-							</vue-cropper>
+			<div v-else-if="page == 'crop'" class="d-flex justify-content-center flex-fill" style="position: relative;height: 90vh;">
+				<vue-cropper
+					class="w-100 h-100 p-0"
+					ref="croppa"
+					:aspectRatio="cropper.aspectRatio"
+					:viewMode="3"
+					:dragMode="'move'"
+					:autoCropArea="1"
+					:guides="false"
+					:highlight="false"
+					:cropBoxMovable="false"
+					:cropBoxResizable="false"
+					:toggleDragModeOnDblclick="false"
+					:src="mediaUrl"
+				>
+				</vue-cropper>
+				<div class="crop-container">
+					<div class="d-flex justify-content-between align-items-center">
+						<button
+							type="button"
+							class="btn btn-outline-muted rounded-pill font-weight-bold px-4"
+							@click="deleteCurrentStory()">
+							Cancel
+						</button>
+
+						<div class="text-center">
+							<h4 class="font-weight-light text-light mb-n1">Crop</h4>
+							<span class="small text-light">Pan around and pinch to zoom</span>
 						</div>
+
+						<button
+							type="button"
+							class="btn btn-outline-light rounded-pill font-weight-bold px-4"
+							@click="performCrop()">
+							Next
+						</button>
 					</div>
 				</div>
 			</div>
 
-			<!-- ERROR -->
-			<div v-if="page == 'error'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
-				<p class="h3 mb-0 text-light">Oops!</p>
-				<p class="text-muted lead">An error occurred, please try again later.</p>
-				<p class="text-muted mb-0">
-					<a class="btn btn-outline-secondary py-0 px-5 font-weight-bold" href="/">Go back</a>
-				</p>
+			<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;">
+				<div class="text-center flex-fill pt-3">
+					<img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
+					<p class="lead text-lighter font-weight-light mb-0">Stories</p>
+				</div>
+				<div class="flex-fill text-center">
+					<p class="h3 mb-0 text-light">Oops!</p>
+					<p class="text-muted lead">An error occurred, please try again later.</p>
+					<p class="text-muted mb-0">
+						<a class="btn btn-outline-muted py-0 px-5 rounded-pill font-weight-bold" href="/">Go back</a>
+					</p>
+				</div>
 			</div>
 
-			<!-- UPLOADING -->
-			<div v-if="page == 'uploading'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
-				<p v-if="uploadProgress != 100" class="display-4 mb-0 text-muted">Uploading {{uploadProgress}}%</p>
-				<p v-else class="display-4 mb-0 text-muted">Processing ...</p>
+			<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;">
+				<div class="spinner-border text-lighter" role="status">
+					<span class="sr-only">Loading...</span>
+				</div>
 			</div>
 
-			<!-- CROPPING -->
-			<div v-if="page == 'cropping'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
-				<p class="display-4 mb-0 text-muted">Cropping ...</p>
+			<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;">
+				<div class="spinner-border text-lighter" role="status">
+					<span class="sr-only">Loading...</span>
+				</div>
 			</div>
 
-			<!-- PREVIEW -->
-			<div v-if="page == 'preview'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
-				<div>
-					<div class="form-group">
+			<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;">
+				<div class="text-center flex-fill pt-3">
+					<img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
+					<p class="lead text-lighter font-weight-light mb-0">Stories</p>
+				</div>
+				<div class="flex-fill">
+					<div class="form-group pb-3">
+						<label for="durationSlider" class="text-light lead font-weight-bold">Options</label>
+						<div class="custom-control custom-checkbox mb-2">
+							<input type="checkbox" class="custom-control-input" id="optionReplies" v-model="canReply">
+							<label class="custom-control-label text-light font-weight-lighter" for="optionReplies">Allow replies</label>
+						</div>
+						<div class="custom-control custom-checkbox mb-2">
+							<input type="checkbox" class="custom-control-input" id="formReactions" v-model="canReact">
+							<label class="custom-control-label text-light font-weight-lighter" for="formReactions">Allow reactions</label>
+						</div>
+					</div>
+					<div v-if="!canPostPoll" class="form-group">
+						<video ref="previewVideo" v-if="mediaType == 'video'" class="mb-4 w-100" style="max-height:200px;object-fit:contain;">
+							<source :src="mediaUrl" type="video/mp4">
+						</video>
 						<label for="durationSlider" class="text-light lead font-weight-bold">Story Duration</label>
-						<input type="range" class="custom-range" min="3" max="10" id="durationSlider" v-model="duration">
+						<input type="range" class="custom-range" min="3" :max="max_duration" step="1" id="durationSlider" v-model="duration">
 						<p class="help-text text-center">
 							<span class="text-light">{{duration}} seconds</span>
 						</p>
 					</div>
-					<hr class="my-3">
-					<a class="btn btn-primary btn-block px-5 font-weight-bold my-3" href="#" @click.prevent="shareStoryToFollowers()">
-						Share Story with followers
-					</a>
-
-					<a class="btn btn-outline-muted btn-block px-5 font-weight-bold" href="/" @click.prevent="deleteCurrentStory()">
-						Cancel
-					</a>
 				</div>
-				<!-- <a class="btn btn-outline-secondary btn-block px-5 font-weight-bold" href="#">
-					Share Story with everyone
-				</a> -->
+				<div class="flex-fill w-100 px-md-5">
+					<div class="d-flex">
+						<a class="btn btn-outline-muted btn-block font-weight-bold my-3 mr-3 rounded-pill" href="/" @click.prevent="deleteCurrentStory()">
+							Cancel
+						</a>
+
+						<a class="btn btn-primary btn-block font-weight-bold my-3 rounded-pill" href="#" @click.prevent="shareStoryToFollowers()">
+							Post {{ canPostPoll ? 'Poll' : 'Story'}}
+						</a>
+					</div>
+				</div>
 			</div>
 
-			<!-- EDIT -->
-			<div v-if="page == 'edit'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
+			<div v-else-if="page == 'edit'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
 				<div class="text-center flex-fill mt-5">
 					<p class="text-muted font-weight-light mb-1">
-						<i class="fas fa-history fa-5x"></i>
+						<i class="fal fa-history fa-5x"></i>
 					</p>
 					<p class="text-muted font-weight-bold mb-0">STORIES</p>
 				</div>
@@ -162,14 +175,14 @@
 										<img :src="story.src" class="rounded-circle border" width="40px" height="40px" style="object-fit: cover;">
 									</div>
 									<div class="media-body text-left">
-										<p class="mb-0 text-muted font-weight-bold"><span>{{story.created_ago}} ago</span></p>
+										<p class="mb-0 text-muted font-weight-bold"><span>{{timeago(story.created_at)}} ago</span></p>
 									</div>
 									<div class="flex-grow-1 text-right">
 										<button v-if="story.viewers.length" @click="toggleShowViewers(index)" class="btn btn-link btn-sm mr-1">
-											<i class="fas fa-eye fa-lg text-muted"></i>
+											<i class="fal fa-eye fa-lg text-muted"></i>
 										</button>
 										<button @click="deleteStory(story, index)" class="btn btn-link btn-sm">
-											<i class="fas fa-trash-alt fa-lg text-muted"></i>
+											<i class="fal fa-trash-alt fa-lg text-muted"></i>
 										</button>
 									</div>
 								</div>
@@ -188,8 +201,58 @@
 					<a class="btn btn-outline-secondary btn-block px-5 font-weight-bold" href="/i/stories/new" @click.prevent="goBack()">Go back</a>
 				</div>
 			</div>
+
+			<div v-else-if="page == 'createPoll'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
+				<div class="text-center pt-3">
+					<img class="mb-2" src="/img/pixelfed-icon-color.svg" width="70" height="70">
+					<p class="lead text-lighter font-weight-light mb-0">Stories</p>
+				</div>
+				<div class="flex-fill mt-3">
+					<div class="align-items-center">
+						<div class="form-group mb-5">
+							<label class="font-weight-bold text-lighter">Poll Question</label>
+							<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" />
+						</div>
+						<label class="font-weight-bold text-lighter">Poll Answers</label>
+						<div v-for="(option, index) in pollOptions" class="form-group mb-4">
+							<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]" />
+						</div>
+						<div v-if="pollOptions.length < 4" class="mb-3">
+							<button
+								class="btn btn-block font-weight-bold rounded-pill shadow"
+								:class="[ (pollQuestion && pollQuestion.length) > 6 && (pollOptions.length == 0 || pollOptions.length && pollOptions[pollOptions.length - 1].length > 3) ? 'btn-muted' : 'btn-outline-muted' ]"
+								:disabled="!pollQuestion || pollQuestion.length < 6"
+								@click="addOptionInput">
+								Add poll option
+							</button>
+						</div>
+						<!-- <div v-for="(option, index) in pollOptions" class="form-group mb-4 d-flex align-items-center" style="max-width:400px;position: relative;">
+							<span class="font-weight-bold mr-2" style="position: absolute;left: 10px;">{{ index + 1 }}.</span>
+							<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;">
+							<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>
+							<button class="btn btn-danger btn-sm rounded-pill font-weight-bold" style="position: absolute;right: 5px;" @click="deletePollOption(index)">
+								<i class="fas fa-trash"></i> Delete
+							</button>
+						</div> -->
+					</div>
+				</div>
+				<div class="flex-fill text-center">
+					<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>
+					<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>
+				</div>
+			</div>
+		</div>
+	</div>
+	<div v-else class="row">
+		<div class="col-12 col-md-6 offset-md-3 bg-dark rounded-lg px-0" style="height: 90vh;">
+			<div class="w-100 h-100 d-flex justify-content-center align-items-center">
+				<div class="spinner-border text-lighter" role="status">
+					<span class="sr-only">Loading...</span>
+				</div>
+			</div>
 		</div>
 	</div>
+
 	<b-modal
 		id="lightbox"
 		ref="lightboxModal"
@@ -207,19 +270,11 @@
 </div>
 </template>
 
-<style type="text/css">
-.bg-black {
-	background-color: #262626;
-}
-#lightbox .modal-content {
-	background: transparent;
-}
-</style>
-
 <script type="text/javascript">
 	import VueTimeago from 'vue-timeago';
 	import VueCropper from 'vue-cropperjs';
 	import 'cropperjs/dist/cropper.css';
+
 	export default {
 		components: {
 			VueCropper,
@@ -234,7 +289,7 @@
 				mimes: [
 					'image/jpeg',
 					'image/png',
-					// 'video/mp4'
+					'video/mp4'
 				],
 				page: 'landing',
 				pages: [
@@ -243,36 +298,79 @@
 					'edit',
 					'confirm',
 					'error',
-					'uploading'
+					'uploading',
+					'createPoll'
 				],
 				uploading: false,
 				uploadProgress: 0,
 				cropper: {
 					aspectRatio: 9/16,
-					viewMode: 2,
+					viewMode: 3,
 					zoomable: true,
 					zoom: null
 				},
 				mediaUrl: null,
 				mediaId: null,
+				mediaType: null,
 				stories: [],
 				lightboxMedia: false,
-				duration: 3
+				duration: 10,
+				canReply: true,
+				canReact: true,
+				poll: {
+					question: null,
+					options: []
+				},
+				pollQuestion: null,
+				pollOptions: [],
+				canPostPoll: false,
+				max_duration: 15
 			};
 		},
 
+		watch: {
+			duration: function(val) {
+				if(this.mediaType == 'video') {
+					this.$refs.previewVideo.currentTime = val;
+					this.$refs.previewVideo.play();
+				}
+			},
+
+			pollQuestion: function(val) {
+				if(val.length < 6) {
+					this.canPostPoll = false;
+				}
+			},
+
+			pollOptions: function(val) {
+				let len = this.pollOptions.filter(o => {
+					return o.length >= 2;
+				});
+
+				if(len.length >= 2) {
+					this.canPostPoll = true;
+				} else {
+					this.canPostPoll = false;
+				}
+			}
+		},
+
 		mounted() {
 			$('body').addClass('bg-black');
 			this.mediaWatcher();
-			axios.get('/api/stories/v0/fetch/' + this.profileId)
-			.then(res => {
-				this.stories = res.data.map(s => {
-					s.showViewers = false;
-					s.viewers = [];
-					return s;
+			setTimeout(() => {
+				axios.get('/api/web/stories/v1/profile/' + this.profileId)
+				.then(res => {
+					if(res.data.length) {
+						this.stories = res.data[0].nodes.map(s => {
+							s.showViewers = false;
+							s.viewers = [];
+							return s;
+						});
+					}
+					this.loaded = true;
 				});
-				this.loaded = true;
-			});
+			}, 400);
 		},
 
 		methods: {
@@ -321,23 +419,28 @@
 					};
 
 					io.value = null;
-					axios.post('/api/stories/v0/add', form, xhrConfig)
+					axios.post('/api/web/stories/v1/add', form, xhrConfig)
 					.then(function(e) {
 						self.uploadProgress = 100;
 						self.uploading = false;
 						self.mediaUrl = e.data.media_url;
 						self.mediaId = e.data.media_id;
+						self.mediaType = e.data.media_type;
 						self.page = e.data.media_type === 'video' ? 'preview' : 'crop';
+						if(e.data.hasOwnProperty('media_duration')) {
+							self.max_duration = e.data.media_duration;
+						}
 						// window.location.href = '/i/my/story';
 					}).catch(function(e) {
 						self.uploading = false;
 						io.value = null;
-						let msg = e.response.data.message ? e.response.data.message : 'Something went wrong.'
+						let msg = e.response.data.message ? e.response.data.message : e.response.data.error ? e.response.data.error :'Something went wrong.'
 						swal('Oops!', msg, 'warning');
 						self.page = 'error';
 					});
 					self.uploadProgress = 0;
 				});
+				document.querySelector('#pf-dz').value = '';
 			},
 
 			expiresTimestamp(ts) {
@@ -361,7 +464,7 @@
 					return;
 				}
 
-				axios.delete('/api/stories/v0/delete/' + story.id)
+				axios.delete('/api/web/stories/v1/delete/' + story.id)
 				.then(res => {
 					this.stories.splice(index, 1);
 					if(this.stories.length == 0) {
@@ -381,7 +484,7 @@
 			performCrop() {
 				this.page = 'cropping';
 				let data = this.$refs.croppa.getData();
-				axios.post('/api/stories/v0/crop', {
+				axios.post('/api/web/stories/v1/crop', {
 					media_id: this.mediaId,
 					width: data.width,
 					height: data.height,
@@ -401,12 +504,25 @@
 			},
 
 			shareStoryToFollowers() {
-				axios.post('/api/stories/v0/publish', {
-					media_id: this.mediaId,
-					duration: this.duration
-				}).then(res => {
-					window.location.href = '/i/my/story?id=' + this.mediaId;
-				})
+				if(this.canPostPoll) {
+					axios.post('/api/web/stories/v1/publish/poll', {
+						question: this.pollQuestion,
+						options: this.pollOptions,
+						can_reply: this.canReply,
+						can_react: this.canReact
+					}).then(res => {
+						window.location.href = '/i/my/story?id=' + this.mediaId;
+					})
+				} else {
+					axios.post('/api/web/stories/v1/publish', {
+						media_id: this.mediaId,
+						duration: this.duration,
+						can_reply: this.canReply,
+						can_react: this.canReact
+					}).then(res => {
+						window.location.href = '/i/my/story?id=' + this.mediaId;
+					})
+				}
 			},
 
 			viewMyStory() {
@@ -415,7 +531,62 @@
 
 			toggleShowViewers(index) {
 				this.stories[index].showViewers = this.stories[index].showViewers ? false : true;
+			},
+
+			timeago(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			newPoll() {
+				this.page = 'createPoll';
+			},
+
+			addOptionInput() {
+				let c = this.pollOptions.filter(o => {
+					return o.length < 3;
+				});
+				if(c.length) {
+					return;
+				}
+				this.pollOptions.push([]);
+			},
+
+			pollPreview() {
+				let opts = this.pollOptions;
+				let dd = [...new Set(this.pollOptions)];
+				if(dd.length != opts.length) {
+					swal('Oops!', 'You cannot use duplicate poll answers, please remove any duplicates and try again.', 'error');
+					return;
+				}
+				this.page = 'preview';
 			}
 		}
 	}
 </script>
+
+<style lang="scss">
+	.bg-black {
+		background-color: #262626;
+	}
+</style>
+<style lang="scss" scoped>
+	.story-compose-component {
+		#lightbox .modal-content {
+			background: transparent;
+		}
+
+		::placeholder {
+			color: #ccc;
+		}
+
+		.crop-container {
+			z-index: 9;
+			position: absolute;
+			top: 0;
+			width: 100%;
+			min-height: 100px;
+			padding: 15px 30px;
+			background: linear-gradient(180deg, rgba(38,38,38, 0.8) 0%, rgba(38,38,38,0) 100%);
+		}
+	}
+</style>

+ 16 - 1
resources/assets/js/components/StoryTimelineComponent.vue

@@ -11,7 +11,8 @@
 					v-for="(story, index) in stories"
 					class="px-3 pt-3 text-center cursor-pointer"
 					:class="{ seen: story.seen }"
-					@click="showStory(index)">
+					@click="showStory(index)"
+					>
 					<span
 						:class="[
 							story.seen ? 'not-seen' : '',
@@ -24,6 +25,8 @@
 						class="small font-weight-bold text-truncate"
 						:class="{ 'text-lighter': story.seen }"
 						style="max-width: 69px"
+						v-b-tooltip.hover
+						placement="bottom"
 						:title="story.username"
 						>
 						{{story.username}}
@@ -118,6 +121,18 @@
 			background: #fff;
 			padding: 3px;
 		}
+
+		&.new {
+			background: none;
+			width: 70px;
+			height: 70px;
+			border: dashed 4px #d92d77;
+
+			img {
+				width: 56px;
+				height: 56px;
+			}
+		}
 	}
 
 	.scrolly {

+ 942 - 84
resources/assets/js/components/StoryViewer.vue

@@ -1,129 +1,987 @@
 <template>
-<div class="container">
-	<div v-if="loading" class="row">
-		<div class="col-12 mt-5 pt-5">
-			<div class="text-center">
-				<div class="spinner-border" role="status">
-					<span class="sr-only">Loading...</span>
+<div
+	class="story-viewer-component container mt-0 mt-md-5 bg-black">
+	<button type="button" class="d-none d-md-block btn btn-link fixed-top" style="left: auto;right:0;" @click="backToFeed">
+		<i class="fal fa-times-circle fa-2x text-lighter"></i>
+	</button>
+
+	<div v-if="!viewWarning" class="row d-flex justify-content-center align-items-center">
+		<div class="d-none d-md-block col-md-1 cursor-pointer text-center" @click="prev">
+			<div v-if="storyIndex > 0">
+				<i class="fas fa-chevron-circle-left text-muted fa-2x"></i>
+			</div>
+		</div>
+		<div v-if="!loading" class="col-12 col-md-6 rounded-lg">
+
+			<div v-if="activeReactionEmoji" style="position: absolute;z-index: 999;" class="w-100 h-100 d-flex justify-content-center align-items-center">
+				<div class="d-flex justify-content-center align-items-center rounded-pill shadow-lg" style="width: 120px;height: 30px;font-size:13px;background-color: rgba(0, 0, 0, 0.6);">
+					<span class="text-lighter">Reaction sent</span>
+				</div>
+			</div>
+
+			<div v-if="activeReply" style="position: absolute;z-index: 999;" class="w-100 h-100 d-flex justify-content-center align-items-center">
+				<div class="d-flex justify-content-center align-items-center rounded-pill shadow-lg" style="width: 120px;height: 30px;font-size:13px;background-color: rgba(0, 0, 0, 0.6);">
+					<span class="text-lighter">Reply sent</span>
+				</div>
+			</div>
+
+			<transition name="fade">
+			<div v-if="stories[storyIndex].type == 'photo'" class="media-slot rounded-lg" :key="'msl:'+storyIndex" :style="{ background: 'url(' + stories[storyIndex].url + ')' }"></div>
+
+			<div v-else-if="stories[storyIndex].type == 'poll'" class="media-slot rounded-lg" :key="'msl:'+storyIndex" :style="{ background: 'linear-gradient(to right, #F27121, #E94057, #8A2387)' }"></div>
+
+			<video
+				v-else-if="stories[storyIndex].type == 'video'"
+				:key="'plyr'+stories[storyIndex].id"
+				id="playr"
+				class="media-slot rounded-lg"
+				style="object-fit: contain;"
+				:muted="muted"
+				loop
+				autoplay
+				no-controls>
+				<source :src="stories[storyIndex].url" type="video/mp4">
+			</video>
+			</transition>
+
+			<div class="story-viewer-component-card card bg-transparent border-0 shadow-none d-flex justify-content-center">
+				<div class="card-body">
+					<div class="px-0 top-overlay">
+						<div class="pt-4 pt-md-3 px-4 d-flex">
+							<div style="width: 100%;height:5px;" class="d-none bg-muted"></div>
+							<div
+								v-for="(story, index) in stories"
+								:key="'sp:s'+index"
+								v-on:click="gotoSlide(index)"
+								class="w-100 cursor-pointer"
+								:class="{ 'mr-2': index != stories.length - 1 }">
+								<div
+									class="progress w-100"
+									style="z-index:3;height: 4px;"
+									:style="{opacity: story.progress == 0 ? 0.7 : 0.8}">
+									<div
+										:key="'sp:si'+index"
+										class="progress-bar bg-light"
+										role="progressbar"
+										:aria-valuenow="story.progress"
+										aria-valuemin="0"
+										aria-valuemax="100"
+										:style="{
+											width: story.progress +'%',
+											transition: 'none !important'
+										}">
+									</div>
+								</div>
+							</div>
+						</div>
+						<div class="pt-4 px-4 media align-items-center">
+							<img :src="avatar" width="32" height="32" class="rounded-circle mr-2" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+							<div class="media-body d-flex justify-content-between align-items-center">
+								<div class="user-select-none d-flex align-items-center">
+									<span v-if="account.local" class="text-white font-weight-bold mr-2">
+										{{username}}
+									</span>
+									<span v-else class="text-white font-weight-bold mr-3 text-truncate" style="max-width:200px;">
+										<span class="d-block mb-n2">{{account.username}}</span>
+										<span class="small">{{account.domain}}</span>
+									</span>
+									<span class="text-white font-weight-light" style="font-size: 14px;">{{timeago(stories[storyIndex].created_at)}}</span>
+									<span v-if="stories[storyIndex].type == 'poll'">
+										<span class="btn btn-outline-light font-weight-light btn-sm px-1 rounded py-0 ml-2">POLL</span>
+									</span>
+								</div>
+								<div>
+									<button class="btn btn-link btn-sm text-white mr-0 px-1" @click.prevent="pause">
+										<i :class="[ paused ? 'fa-play' : 'fa-pause' ]" class="fas fa-lg"></i>
+									</button>
+
+									<button v-if="stories[storyIndex].type == 'video'" class="btn btn-link text-white px-2" @click="toggleMute">
+										<i :class="[ muted ? 'fa-volume-mute' : 'fa-volume-up' ]" class="fas fa-lg"></i>
+									</button>
+
+									<button @click="showMenu" class="btn btn-link text-white px-1">
+										<i class="fas fa-ellipsis-h fa-lg"></i>
+									</button>
+
+									<button class="d-inline-block d-md-none btn btn-link text-white pl-1 pr-0" @click="backToFeed">
+										<i class="far fa-times-circle fa-lg"></i>
+									</button>
+								</div>
+							</div>
+						</div>
+					</div>
+					<div @click="pause" style="height: 70vh;">
+						<div v-if="stories[storyIndex].type == 'poll'" class="w-100 h-100 d-flex justify-content-center align-items-center">
+							<div>
+								<p
+									class="text-white pb-5 text-break font-weight-lighter"
+									:class="[stories[storyIndex].question.length < 60 ? 'h1' : 'h3']">
+									{{stories[storyIndex].question}}
+								</p>
+								<div class="text-center mt-3">
+									<div v-for="(option, index) in stories[storyIndex].options" class="mb-3">
+										<button
+											class="btn border px-4 py-3 text-uppercase btn-block"
+											:class="[
+												option.length < 14 ? 'btn-lg': '',
+												index == stories[storyIndex].voted_index ? 'btn-light' : 'btn-outline-light'
+											]"
+											style="min-width: 300px;"
+											:disabled="stories[storyIndex].voted || owner"
+											@click="selectPollOption(index)">
+											<span
+												class="text-break"
+												:class="[
+													index == stories[storyIndex].voted_index ? 'option-red' : ''
+												]">
+												{{ option }}
+											</span>
+										</button>
+										<p
+											v-if="owner && pollResults.length"
+											class="small text-left mt-1 text-light">
+												{{ pollPercent(index) }}% - {{ pollResults[index] }} {{ pollResults[index] == 1 ? 'vote' : 'votes' }}
+										</p>
+									</div>
+								</div>
+								<div v-if="owner && !showingPollResults && pollResults.length == 0" class="mt-3 text-center">
+									<button class="btn btn-light font-weight-bold" @click="showPollResults" :disabled="loadingPollResults">
+										{{ loadingPollResults ? 'Loading...' : 'View Results' }}
+									</button>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div v-if="!owner && stories[storyIndex] && stories[storyIndex].can_reply" class="card-footer bg-transparent border-0">
+					<div class="px-0 bottom-overlay">
+						<div class="px-3 form-group d-flex">
+							<input class="form-control bg-transparent border border-white rounded-pill text-white" :placeholder="'Reply to ' + username + '...'" v-model="composeText">
+							<button class="btn btn-outline-light rounded-pill ml-2" @click="comment">
+								SEND
+							</button>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div class="d-none d-md-block col-md-1 cursor-pointer text-center">
+			<div v-if="(storyIndex + 1) < stories.length" @click="next">
+				<i class="fas fa-chevron-circle-right text-muted fa-2x"></i>
+			</div>
+			<div v-if="(storyIndex + 1) == stories.length && owner" @click="addToStory">
+				<i class="fal fa-plus-circle text-muted fa-2x"></i>
+			</div>
+		</div>
+		<div v-if="loading" class="col-12 col-md-6 rounded-lg">
+			<div class="card border-0 shadow-none d-flex justify-content-center" style="background: #000;height: 90vh;">
+				<div class="card-body d-flex justify-content-center align-items-center">
+					<div class="spinner-border text-lighter" role="status">
+						<span class="sr-only">Loading...</span>
+					</div>
 				</div>
 			</div>
 		</div>
 	</div>
-	<div v-if="stories.length != 0">
-		<div id="storyContainer" class="d-none m-3"></div>
+
+	<div v-else class="row d-flex justify-content-center align-items-center">
+		<div v-if="!loading" class="col-12 col-md-6 rounded-lg p-0">
+			<div v-if="stories[storyIndex].type == 'photo'" class="media-slot rounded-lg" :key="'msl:'+storyIndex" :style="{ backgroundImage: 'url(' + stories[storyIndex].url + ')' }"></div>
+			<div class="story-viewer-component-card card bg-transparent border-0 shadow-none d-flex justify-content-center" style="backdrop-filter: blur(40px) brightness(0.3); -webkit-backdrop-filter: blur(10px);">
+				<div class="card-body">
+					<div class="w-100 h-100 d-flex justify-content-center align-items-center">
+						<div class="text-center">
+							<img :src="profile.avatar" width="120" height="120" class="rounded-circle border mb-3 shadow">
+							<p class="lead text-lighter mb-1">View as <span class="text-white">{{profile.username}}</span></p>
+							<p class="text-lighter font-weight-lighter px-md-5 py-3">
+								<span class="text-white font-weight-bold">{{account.acct}}</span> will be able to see that you viewed their story.
+							</p>
+							<button class="btn btn-outline-lighter rounded-pill py-1 font-weight-bold" @click="confirmViewStory">View Story</button>
+							<button class="btn btn-outline-lighter rounded-pill py-1 font-weight-bold" @click="cancelViewStory">Cancel</button>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+
+	<div class="modal-stack">
+		<b-modal ref="ctxMenu"
+			id="ctx-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group text-center">
+				<div v-if="owner" class="list-group-item rounded py-3">
+					<div class="d-flex justify-content-between align-items-center font-weight-light">
+						<span>Expires in {{timeahead(stories[storyIndex].expires_at)}}</span>
+						<span>
+							<span class="btn btn-light btn-sm font-weight-bold">
+								<i class="fas fa-eye"></i>
+								{{ stories[storyIndex].view_count }}
+							</span>
+						</span>
+					</div>
+				</div>
+				<div v-if="!owner && stories[storyIndex] && stories[storyIndex].can_react" class="list-group-item rounded d-flex justify-content-between">
+					<button
+						v-for="e in reactionEmoji"
+						class="btn btn-light rounded-pill py-1 px-2"
+						style="font-size: 20px;"
+						@click="react(e)">
+						{{ e }}
+					</button>
+				</div>
+				<div v-if="owner" class="list-group-item rounded cursor-pointer" @click="fetchViewers">Viewers</div>
+				<div v-if="!owner" class="list-group-item rounded cursor-pointer" @click="ctxMenuReport">Report</div>
+				<div v-if="owner" class="list-group-item rounded cursor-pointer" @click="deleteStory">Delete</div>
+				<div class="list-group-item rounded cursor-pointer text-muted" @click="closeCtxMenu">Close</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="viewersModal"
+			id="viewers"
+			title="Viewers"
+			header-class="border-0"
+			hide-footer
+			centered
+			rounded
+			scrollable
+			lazy
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group" style="max-height: 40vh;">
+				<div v-for="(profile, index) in viewers" class="list-group-item">
+					<div class="media align-items-center">
+						<img :src="profile.avatar" width="32" height="32" class="rounded-circle border mr-2">
+						<div v-if="profile.local" class="media-body user-select-none">
+							<p class="font-weight-bold mb-0">{{profile.username}}</p>
+						</div>
+						<div v-else class="media-body user-select-none">
+							<p class="font-weight-bold mb-0">{{profile.username}}</p>
+							<p class="mb-0 small mt-n1 text-muted">{{profile.acct.split('@')[1]}}</p>
+						</div>
+					</div>
+				</div>
+				<div v-if="viewers.length == 0" class="list-group-item text-center text-dark font-weight-light py-5">
+					No viewers yet
+				</div>
+				<div v-if="viewersHasMore" class="list-group-item text-center border-bottom-0">
+					<button class="btn btn-light font-weight-bold border rounded-pill" @click="viewersLoadMore">Load More</button>
+				</div>
+				<div class="list-group-item text-center rounded cursor-pointer text-muted" @click="closeViewersModal">Close</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxReport"
+			id="ctx-report"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<p class="py-2 px-3 mb-0">
+				<div class="text-center font-weight-bold text-danger">Report</div>
+				<div class="small text-center text-muted">Select one of the following options</div>
+			</p>
+			<div class="list-group text-center">
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('spam')">Spam</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('sensitive')">Sensitive Content</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('abusive')">Abusive or Harmful</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="openCtxReportOtherMenu()">Other</div>
+				<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxReportMenuGoBack()">Go Back</div> -->
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxReportMenuGoBack()">Cancel</div>
+			</div>
+		</b-modal>
+
+		<b-modal ref="ctxReportOther"
+			id="ctx-report-other"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<p class="py-2 px-3 mb-0">
+				<div class="text-center font-weight-bold text-danger">Report</div>
+				<div class="small text-center text-muted">Select one of the following options</div>
+			</p>
+			<div class="list-group text-center">
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('underage')">Underage Account</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('copyright')">Copyright Infringement</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('impersonation')">Impersonation</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('scam')">Scam or Fraud</div>
+				<!-- <div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('terrorism')">Terrorism Related</div> -->
+				<!-- <div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('other')">Other or Not listed</div> -->
+				<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxReportOtherMenuGoBack()">Go Back</div> -->
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxReportOtherMenuGoBack()">Cancel</div>
+			</div>
+		</b-modal>
 	</div>
 </div>
 </template>
 
 <script type="text/javascript">
-	import 'zuck.js/dist/zuck.css';
-	import 'zuck.js/dist/skins/snapgram.css';
-	window.Zuck = require('zuck.js');
-
 	export default {
-		props: ['pid'],
+		props: {
+			pid: {
+				type: String
+			},
+
+			selfProfile: {
+				type: Object
+			},
+
+			redirectUrl: {
+				type: String,
+				default: '/'
+			}
+		},
 
 		data() {
 			return {
 				loading: true,
-				stories: {},
-				preloadIndex: null
+				profile: null,
+				account: {
+					local: false
+				},
+				owner: false,
+				stories: [],
+			    username: 'loading...',
+			    avatar: '/storage/avatars/default.jpg',
+				storyIndex: 0,
+				progress: 0,
+				constInterval: 383,
+				progressInterval: undefined,
+				composeText: null,
+				paused: false,
+				muted: true,
+				reactionEmoji: [ "❤️", "🔥", "💯", "😂", "😎", "👀" ],
+				activeReactionEmoji: false,
+				activeReply: false,
+				showProgress: false,
+				redirectOnEnd: '/',
+				viewerSid: false,
+				viewerPage: 1,
+				loadingViewers: false,
+				viewersHasMore: true,
+				viewers: [],
+				viewWarning: false,
+				showingPollResults: false,
+				loadingPollResults: false,
+				pollResults: [],
+				pollTotalVotes: 0
+			}
+		},
+
+		watch: {
+			composeText: function(val) {
+				if(val.length == 0) {
+					if(this.paused) {
+						this.pause();
+					}
+				} else {
+					if(!this.paused) {
+						this.pause();
+					}
+				}
+				event.currentTarget.focus();
 			}
 		},
 
 		beforeMount() {
-			this.fetchStories();
+			this.redirectOnEnd = this.redirectUrl;
+		},
+
+		mounted() {
+			let u = new URLSearchParams(window.location.search);
+			if(u.has('t')) {
+				switch(u.get('t')) {
+					case '1':
+						this.redirectOnEnd = '/';
+					break;
+
+					case '2':
+						this.redirectOnEnd = '/timeline/public';
+					break;
+
+					case '3':
+						this.redirectOnEnd = '/timeline/network';
+					break;
+
+					case '4':
+						this.redirectOnEnd = '/' + window.location.pathname.split('/').slice(-1).pop();
+					break;
+				}
+			} else {
+				this.viewWarning = true;
+			}
+
+			if(!this.selfProfile || !this.selfProfile.hasOwnProperty('avatar')) {
+				axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+				.then(res => {
+					this.profile = res.data;
+					this.fetchStories();
+				});
+			} else {
+				this.profile = this.selfProfile;
+			}
+			let el = document.querySelector('body');
+			el.style.width = '100%';
+			el.style.height = '100vh !important';
+			el.style.overflow = 'hidden';
+			el.style.backgroundColor = '#262626';
 		},
 
 		methods: {
+			init() {
+				clearInterval(this.progressInterval);
+				this.loading = false;
+				this.constInterval = Math.ceil(this.stories[this.storyIndex].duration * 38.3);
+				this.progressInterval = setInterval(() => {
+					this.do();
+				}, this.constInterval);
+			},
+
+			do() {
+				this.loading = false;
+				if(this.stories[this.storyIndex].progress != 100) {
+					this.stories[this.storyIndex].progress = this.stories[this.storyIndex].progress + 4;
+				} else {
+					clearInterval(this.progressInterval);
+					this.next();
+				}
+			},
+
+			prev() {
+				if(this.storyIndex == 0) {
+					return;
+				}
+				this.pollResults = [];
+				this.progress = 0;
+				this.gotoSlide(this.storyIndex - 1);
+			},
+
+			next() {
+				axios.post('/api/web/stories/v1/viewed', {
+					id: this.stories[this.storyIndex].id
+				});
+				this.stories[this.storyIndex].progress = 100;
+				if(this.storyIndex == this.stories.length - 1) {
+					if(this.composeText && this.composeText.length) {
+						return;
+					}
+					window.location.href = this.redirectOnEnd;
+					return;
+				}
+				this.pollResults = [];
+				this.progress = 0;
+				this.muted = true;
+				this.storyIndex = this.storyIndex + 1;
+				this.init();
+			},
+
+			pause() {
+				if(event) {
+					event.currentTarget.blur();
+				}
+
+				if(this.paused) {
+					this.paused = false;
+					if(this.stories[this.storyIndex].type == 'video') {
+						let el = document.getElementById('playr');
+						el.play();
+					}
+					this.init();
+				} else {
+					clearInterval(this.progressInterval);
+					if(this.stories[this.storyIndex].type == 'video') {
+						let el = document.getElementById('playr');
+						el.pause();
+					}
+					this.paused = true;
+				}
+			},
+
+			toggleMute() {
+				if(event) {
+					event.currentTarget.blur();
+				}
+				if(this.stories[this.storyIndex].type == 'video') {
+					this.muted = !this.muted;
+					let el = document.getElementById('playr');
+					el.muted = this.muted;
+				}
+			},
+
+			gotoSlide(index) {
+				this.paused = false;
+				clearInterval(this.progressInterval);
+				this.progressInterval = null;
+				this.stories = this.stories.map(function(s,k) {
+					if(k < index) {
+						s.progress = 100;
+					} else {
+						s.progress = 0;
+					}
+					return s;
+				});
+				this.storyIndex = index;
+				this.stories[index].progress = 0;
+				this.init();
+			},
+
+			showMenu() {
+				if(!this.paused) {
+					this.pause();
+				}
+				event.currentTarget.blur();
+				this.$refs.ctxMenu.show();
+			},
+
+			react(emoji) {
+				this.$refs.ctxMenu.hide();
+				this.activeReactionEmoji = true;
+
+				axios.post('/api/web/stories/v1/react', {
+					sid: this.stories[this.storyIndex].id,
+					reaction: emoji
+				})
+				.then(res => {
+					setTimeout(() => {
+						this.activeReactionEmoji = false;
+						this.pause();
+					}, 2000);
+				}).catch(err => {
+					this.activeReactionEmoji = false;
+					swal('Error', 'An error occured when attempting to react to this story. Please try again later.', 'error');
+				});
+			},
+
+			comment() {
+				if(this.composeText.length < 2) {
+					return;
+				}
+				if(!this.paused) {
+					this.pause();
+				}
+				this.activeReply = true;
+				axios.post('/api/web/stories/v1/comment', {
+					sid: this.stories[this.storyIndex].id,
+					caption: this.composeText
+				})
+				.then(res => {
+					this.composeText = null;
+					setTimeout(() => {
+						this.activeReply = false;
+						this.pause();
+					}, 2000);
+				}).catch(err => {
+					this.activeReply = false;
+					swal('Error', 'An error occured when attempting to reply to this story. Please try again later.', 'error');
+				});
+			},
+
+			closeCtxMenu() {
+				this.$refs.ctxMenu.hide();
+			},
+
+			backToFeed() {
+				if(this.composeText) {
+					swal('Are you sure you want to leave without sending this reply?')
+					.then(confirm => {
+						if(confirm) {
+							window.location.href = this.redirectOnEnd;
+						}
+					})
+					return;
+				} else {
+					window.location.href = this.redirectOnEnd;
+				}
+			},
+
+			timeago(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			timeahead(ts) {
+				let d = new Date(ts);
+				return App.util.format.timeAhead(d.toUTCString());
+			},
+
 			fetchStories() {
 				let self = this;
-				axios.get('/api/stories/v0/profile/' + this.pid)
+				axios.get('/api/web/stories/v1/profile/' + this.pid)
 				.then(res => {
-					self.stories = res.data;
 					if(res.data.length == 0) {
-						window.location.href = '/';
+						window.location.href = this.redirectOnEnd;
+					}
+					self.account = res.data[0].account;
+					if(self.account.local == false) {
+						self.account.domain = self.account.acct.split('@')[1]
+					}
+					self.stories = res.data[0].nodes.map(function(i, k) {
+						let r = {
+							id: i.id,
+							created_at: i.created_at,
+							expires_at: i.expires_at,
+							progress: i.progress == 100 && k == res.data[0].nodes.length - 1 ? 0 : i.progress,
+							view_count: i.view_count,
+							url: i.src,
+							type: i.type,
+							duration: i.duration,
+							can_reply: i.can_reply,
+							can_react: i.can_react,
+						}
+
+						if(r.type == 'poll') {
+							r.question = i.question;
+							r.options = i.options;
+							r.voted = i.voted;
+							r.voted_index = i.voted_index;
+						}
+
+						return r;
+					});
+					self.username = res.data[0].account.username;
+					self.avatar = res.data[0].account.avatar;
+					if(self.profile.id == res.data[0].account.id) {
+						this.viewWarning = false;
+					}
+					if(this.viewWarning) {
+						this.loading = false;
 						return;
 					}
-					self.preloadImages();
+					let seen = res.data[0].nodes.filter(function(i, k) {
+						return i.seen == true;
+					}).map(function(i, k) {
+						return k;
+					});
+					if(seen.length && this.pid != this.profile.id) {
+						let n = (seen[seen.length - 1] + 1) == self.stories.length ? seen[seen.length - 1] : (seen[seen.length - 1] + 1);
+						self.gotoSlide(n);
+					}
+					if(this.pid == this.profile.id) {
+						self.gotoSlide(self.stories.length - 1);
+					}
+					self.showProgress = true;
+					if(self.profile.id == self.account.id) {
+						self.owner = true;
+					}
+					if(res.data.length == 0) {
+						window.location.href = this.redirectOnEnd;
+						return;
+					}
+					this.init();
 				})
 				.catch(err => {
-					console.log(err);
-					// window.location.href = '/';
 					return;
 				});
 			},
 
-			preloadImages() {
+			fetchViewers() {
+				this.closeCtxMenu();
+				this.$refs.viewersModal.show();
+
+				if(this.stories[this.storyIndex].id == this.viewerSid) {
+					return;
+				}
+
+				this.loadingViewers = true;
+
+				axios.get('/api/web/stories/v1/viewers', {
+					params: {
+						sid: this.stories[this.storyIndex].id
+					}
+				}).then(res => {
+					this.viewerSid = this.stories[this.storyIndex].id;
+					this.viewers = res.data;
+					this.loadingViewers = false;
+					this.viewerPage = 2;
+					if(this.viewers.length == 10) {
+						this.viewersHasMore = true;
+					} else {
+						this.viewersHasMore = false;
+					}
+				}).catch(err => {
+					swal('Cannot load viewers', 'Cannot load viewers of this story, please try again later.', 'error');
+				})
+			},
+
+			viewersLoadMore() {
+				axios.get('/api/web/stories/v1/viewers', {
+					params: {
+						sid: this.stories[this.storyIndex].id,
+						page: this.viewerPage
+					}
+				}).then(res => {
+					if(!res.data || res.data.length == 0) {
+						this.viewersHasMore = false;
+						return;
+					}
+					if(res.data.length != 10) {
+						this.viewersHasMore = false;
+					}
+					this.viewers.push(...res.data);
+					this.viewerPage++;
+				}).catch(err => {
+					swal('Cannot load viewers', 'Cannot load viewers of this story, please try again later.', 'error');
+				});
+			},
+
+			closeViewersModal() {
+				this.$refs.viewersModal.hide();
+			},
+
+			deleteStory() {
+				this.closeCtxMenu();
+				if(!window.confirm('Are you sure you want to delete this story?')) {
+					this.pause();
+					return;
+				}
+				axios.delete('/api/web/stories/v1/delete/' + this.stories[this.storyIndex].id)
+				.then(res => {
+					let i = this.storyIndex;
+					let c = this.stories.length;
+
+					if(c == 1) {
+						window.location.href = '/';
+						return;
+					}
+					window.location.reload();
+				});
+			},
+
+			selectPollOption(index) {
+				if(!this.paused) {
+					this.pause();
+				}
+				axios.post('/i/stories/viewed', {
+					id: this.stories[this.storyIndex].id
+				});
+				axios.post('/api/web/stories/v1/poll/vote', {
+					sid: this.stories[this.storyIndex].id,
+					ci: index
+				}).then(res => {
+					this.stories[this.storyIndex].voted = true;
+					this.stories[this.storyIndex].voted_index = index;
+					this.next();
+				})
+			},
+
+			ctxMenuReport() {
+				this.$refs.ctxMenu.hide();
+				this.$refs.ctxReport.show();
+			},
+
+			openCtxReportOtherMenu() {
+				this.closeCtxMenu();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxReportOther.show();
+			},
+
+			ctxReportMenuGoBack() {
+				this.closeMenus();
+			},
+
+			ctxReportOtherMenuGoBack() {
+				this.closeMenus();
+			},
+
+			closeMenus() {
+				this.$refs.ctxReportOther.hide();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxMenu.hide();
+			},
+
+			sendReport(type) {
+				let id = this.stories[this.storyIndex].id;
+
+				swal({
+					'title': 'Confirm Report',
+					'text': 'Are you sure you want to report this post?',
+					'icon': 'warning',
+					'buttons': true,
+					'dangerMode': true
+				}).then((res) => {
+					if(res) {
+						axios.post('/api/web/stories/v1/report', {
+							'type': type,
+							'id': id,
+						}).then(res => {
+							this.closeMenus();
+							swal('Report Sent!', 'We have successfully received your report', 'success');
+						}).catch(err => {
+							if(err.response.status === 409) {
+								swal('Already reported', 'You have already reported this story', 'info');
+							} else {
+								swal('Oops!', 'There was an issue reporting this story', 'error');
+							}
+						})
+					} else {
+						this.closeMenus();
+					}
+				});
+			},
+
+			cancelViewStory() {
+				event.currentTarget.blur();
+				location.href = '/i/web';
+			},
+
+			confirmViewStory() {
 				let self = this;
-				for (var i = 0; i < this.stories[0].items.length; i++) {
-					var preload = new Image();
-					$(preload).on('load', function() {
-
-						self.preloadIndex = i;
-						if(i == self.stories[0].items.length) {
-							self.loadViewer();
-							return;
-						}
-					});
-					preload.src = self.stories[0].items[i].src;
-				}
-			},
-
-			loadViewer() {
-				let data = this.stories;
-
-				if(!window.stories) {
-					window.stories = new Zuck('storyContainer', {
-						stories: data,
-						localStorage: false,
-						callbacks:  {
-							onOpen (storyId, callback) {
-								document.body.style.overflow = "hidden";
-								callback()
-							},
-
-							onEnd (storyId, callback) {
-								axios.post('/i/stories/viewed', {
-									id: storyId
-								});
-								callback();
-							},
-
-							onClose (storyId, callback) {
-								document.body.style.overflow = "auto";
-								callback();
-								window.location.href = '/';
-							},
-						}
-					});
+				let seen = this.stories.filter(function(i, k) {
+					return i.seen == true;
+				}).map(function(i, k) {
+					return k;
+				});
+				if(seen.length && this.pid != this.profile.id) {
+					let n = (seen[seen.length - 1] + 1) == self.stories.length ? seen[seen.length - 1] : (seen[seen.length - 1] + 1);
+					self.gotoSlide(n);
+				}
+				if(this.pid == this.profile.id) {
+					self.gotoSlide(self.stories.length - 1);
+				}
+				self.showProgress = true;
+				if(self.profile.username == self.username) {
+					self.owner = true;
+				}
+				this.viewWarning = false;
+				this.init();
+			},
 
-					this.loading = false;
-					// todo: refactor this mess
-					document.querySelectorAll('#storyContainer .story')[0].click();
+			showPollResults() {
+				this.loadingPollResults = true;
+				if(!this.paused) {
+					this.pause();
 				}
-				return;
+
+				axios.get('/api/web/stories/v1/poll/results', {
+					params: {
+						sid: this.stories[this.storyIndex].id
+					}
+				}).then(res => {
+					this.loadingPollResults = false;
+					this.pollResults = res.data;
+					const sum = (a, b) => a + b;
+					this.pollTotalVotes = this.pollResults.reduce(sum);
+				});
+			},
+
+			addToStory() {
+				window.location.href = '/i/stories/new';
+			},
+
+			pollPercent(index) {
+				return this.pollTotalVotes == 0 ? 0 : Math.round((this.pollResults[index] / this.pollTotalVotes) * 100)
 			}
 		}
 	}
 </script>
 
-<style type="text/css">
-	#storyContainer .story {
-		margin-right: 2rem;
+<style lang="scss" scoped>
+	#content {
 		width: 100%;
-		max-width: 64px;
+		height: 100vh !important;
+		overflow: hidden;
+		background-color: #262626;
 	}
-	.stories.carousel .story > .item-link > .item-preview {
-		height: 64px;
-	}
-	#zuck-modal.with-effects {
-		width: 100%;
-	}
-	.stories.carousel .story > .item-link > .info .name {
-		font-weight: 600;
-		font-size: 12px;
-	}
-	.stories.carousel .story > .item-link > .info {
+
+	.story-viewer-component {
+
+		&-card {
+			height: 100vh;
+
+			@media (min-width: 768px) {
+				height: 90vh;
+			}
+		}
+
+		&.bg-black {
+			background-color: #262626;
+		}
+
+		.option-green {
+			font-size: 20px;
+			font-weight: 600;
+			background: #11998e;  /* fallback for old browsers */
+			background: -webkit-linear-gradient(180deg, #38ef7d, #11998e);
+			background: linear-gradient(180deg, #38ef7d, #11998e);
+			-webkit-background-clip: text;
+  			-webkit-text-fill-color: transparent;
+		}
+
+		.option-red {
+			font-weight: 600;
+			background: linear-gradient(to right, #F27121, #E94057, #8A2387);
+			-webkit-background-clip: text;
+  			-webkit-text-fill-color: transparent;
+		}
+
+		.bg-black {
+			background-color: #262626;
+		}
+
+		.fade-enter-active, .fade-leave-active {
+			transition: opacity .5s;
+		}
+
+		.fade-enter, .fade-leave-to {
+			opacity: 0;
+		}
+
+		.progress {
+			background-color: #979a9a;
+		}
+
+		.media-slot {
+			border-radius: 0;
+			width: 100%;
+			height: 100%;
+			position: absolute;
+			left: 0;
+			top: 0;
+			background: #000;
+			background-size: cover !important;
+			z-index: 0;
+		}
+
+		.card-body {
+			.top-overlay {
+				height:100px;
+				margin-left: -35px;
+				margin-right: -35px;
+				margin-top: -20px;
+				padding-bottom: 20px;
+				border-radius: 5px;
+				background: linear-gradient(180deg, rgba(38,38,38, 0.8) 0%, rgba(38,38,38,0) 100%);
+			}
+		}
+
+		.card-footer {
+			::placeholder {
+				color: #fff;
+				opacity: 1;
+			}
+
+			.bottom-overlay {
+				margin-left: -35px;
+				margin-right: -35px;
+				margin-bottom: -20px;
+				border-radius: 5px;
+				background: linear-gradient(0deg, rgba(38,38,38, 0.8) 0%, rgba(38,38,38,0) 100%);
+
+				.form-group {
+					padding-top: 40px;
+					padding-bottom: 20px;
+					margin-bottom: 0;
+				}
+			}
+		}
 	}
-</style>
+</style>