Răsfoiți Sursa

Add Year in Review feature (mysql only)

Daniel Supernault 4 ani în urmă
părinte
comite
f32072a396

+ 228 - 10
app/Http/Controllers/SeasonalController.php

@@ -4,17 +4,235 @@ namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
 use Auth;
+use App\AccountLog;
+use App\Follower;
+use App\Like;
+use App\Status;
+use App\StatusHashtag;
+use Illuminate\Support\Facades\Cache;
 
 class SeasonalController extends Controller
 {
-    public function __construct()
-    {
-    	$this->middleware('auth');
-    }
-
-    public function yearInReview()
-    {
-    	$profile = Auth::user()->profile;
-    	return view('account.yir', compact('profile'));
-    }
+	public function __construct()
+	{
+		$this->middleware('auth');
+	}
+
+	public function yearInReview()
+	{
+		abort_if(now()->gt('2021-03-01 00:00:00'), 404);
+		abort_if(config('database.default') != 'mysql', 404);
+
+		$profile = Auth::user()->profile;
+		return view('account.yir', compact('profile'));
+	}
+
+	public function getData(Request $request)
+	{
+		abort_if(now()->gt('2021-03-01 00:00:00'), 404);
+		abort_if(config('database.default') != 'mysql', 404);
+
+		$uid = $request->user()->id;
+		$pid = $request->user()->profile_id;
+		$epoch = '2020-01-01 00:00:00';
+		$epochStart = '2020-01-01 00:00:00';
+		$epochEnd = '2020-12-31 23:59:59';
+
+		$siteKey = 'seasonal:my2020:shared';
+		$siteTtl = now()->addMonths(3);
+		$userKey = 'seasonal:my2020:user:' . $uid;
+		$userTtl = now()->addMonths(3);
+
+		$shared = Cache::remember($siteKey, $siteTtl, function() use($epochStart, $epochEnd) {
+			return [
+				'average' => [
+					'posts' => round(Status::selectRaw('*, count(profile_id) as count')
+					->whereNull('uri')
+					->whereIn('type', ['photo','photo:album','video','video:album','photo:video:album'])
+					->where('created_at', '>', $epochStart)
+					->where('created_at', '<', $epochEnd)
+					->groupBy('profile_id')
+					->pluck('count')
+					->avg()),
+
+					'likes' => round(Like::selectRaw('*, count(profile_id) as count')
+					->where('created_at', '>', $epochStart)
+					->where('created_at', '<', $epochEnd)
+					->groupBy('profile_id')
+					->pluck('count')
+					->avg()),
+				],
+
+				'popular' => [
+
+					'hashtag' => StatusHashtag::selectRaw('*,count(hashtag_id) as count')
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->groupBy('hashtag_id')
+						->orderByDesc('count')
+						->take(1)
+						->get()
+						->map(function($sh) {
+							return [
+								'name' => $sh->hashtag->name,
+								'count' => $sh->count
+							];
+						})
+						->first(),
+
+						'post' => Status::whereScope('public')
+						->where('likes_count', '>', 1)
+						->whereIsNsfw(false)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->orderByDesc('likes_count')
+						->take(1)
+						->get()
+						->map(function($status) {
+							return [
+								'id' => (string) $status->id,
+								'username' => (string) $status->profile->username,
+								'created_at' => $status->created_at->format('M d, Y'),
+								'type' => $status->type,
+								'url' => $status->url(),
+								'thumb' => $status->thumb(),
+								'likes_count' => $status->likes_count,
+								'reblogs_count' => $status->reblogs_count,
+								'reply_count' => $status->reply_count ?? 0,
+							];
+						})
+						->first(),
+
+						'places' => Status::selectRaw('*, count(place_id) as count')
+						->whereNotNull('place_id')
+						->having('count', '>', 1)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->groupBy('place_id')
+						->orderByDesc('count')
+						->take(1)
+						->get()
+						->map(function($sh) {
+							return [
+								'name' => $sh->place->getName(),
+								'url' => $sh->place->url(),
+								'count' => $sh->count
+							];
+						})
+					->first()
+				],
+
+			];
+		});
+
+		$res = Cache::remember($userKey, $userTtl, function() use($uid, $pid, $epochStart, $epochEnd, $request) {
+			return [
+				'account' => [
+					'user_id' => $request->user()->id,
+					'created_at' => $request->user()->created_at->format('M d, Y'),
+					'created_this_year' => $request->user()->created_at->gt('2020-01-01 00:00:00'),
+					'created_months_ago' => $request->user()->created_at->diffInMonths(now()),
+					'followers_this_year' => Follower::whereFollowingId($pid)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->count(),
+					'followed_this_year' => Follower::whereProfileId($pid)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->count(),
+					'most_popular' => Status::whereProfileId($pid)
+						->where('likes_count', '>', 1)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->orderByDesc('likes_count')
+						->take(1)
+						->get()
+						->map(function($status) {
+							return [
+								'id' => (string) $status->id,
+								'username' => (string) $status->profile->username,
+								'created_at' => $status->created_at->format('M d, Y'),
+								'type' => $status->type,
+								'url' => $status->url(),
+								'thumb' => $status->thumb(),
+								'likes_count' => $status->likes_count,
+								'reblogs_count' => $status->reblogs_count,
+								'reply_count' => $status->reply_count ?? 0,
+							];
+						})
+					->first(),
+					'posts_count' => Status::whereProfileId($pid)
+						->whereIn('type', ['photo','photo:album','video','video:album','photo:video:album'])
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->count(),
+					'likes_count' => Like::whereProfileId($pid)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->count(),
+					'hashtag' => StatusHashtag::selectRaw('*, count(hashtag_id) as count')
+						->whereProfileId($pid)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->groupBy('profile_id')
+						->orderByDesc('count')
+						->take(1)
+						->get()
+						->map(function($sh) {
+							return [
+								'name' => $sh->hashtag->name,
+								'count' => $sh->count
+							];
+						})
+					->first(),
+					'places' => Status::selectRaw('*, count(place_id) as count')
+						->whereNotNull('place_id')
+						->having('count', '>', 1)
+						->whereProfileId($pid)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->groupBy('place_id')
+						->orderByDesc('count')
+						->take(1)
+						->get()
+						->map(function($sh) {
+							return [
+								'name' => $sh->place->getName(),
+								'url' => $sh->place->url(),
+								'count' => $sh->count
+							];
+						})
+					->first(),
+					'places_total' => Status::whereProfileId($pid)
+						->where('created_at', '>', $epochStart)
+						->where('created_at', '<', $epochEnd)
+						->whereNotNull('place_id')
+						->count()
+				]
+			];
+		});
+
+		return response()->json(array_merge($res, $shared));
+	}
+
+	public function store(Request $request)
+	{
+		abort_if(now()->gt('2021-03-01 00:00:00'), 404);
+		abort_if(config('database.default') != 'mysql', 404);
+		$this->validate($request, [
+			'profile_id' => 'required',
+			'type' => 'required|string|in:view,hide'
+		]);
+
+		$user = $request->user();
+
+		$log = new AccountLog();
+		$log->user_id = $user->id;
+		$log->item_type = 'App\User';
+		$log->item_id = $user->id;
+		$log->action = $request->input('type') == 'view' ? 'seasonal.my2020.view' : 'seasonal.my2020.hide';
+		$log->ip_address = $request->ip();
+		$log->user_agent = $request->user_agent();
+		$log->save();
+	}
 }

+ 228 - 0
resources/assets/js/components/My2020.vue

@@ -0,0 +1,228 @@
+<template>
+<div class="bg-dark text-white">
+	<div v-if="!loaded" style="height: 100vh;" class="d-flex justify-content-center align-items-center">
+		<div class="text-center">
+			<div class="spinner-border text-light" role="status">
+				<span class="sr-only">Loading...</span>
+			</div>
+			<p class="mb-0 lead mt-2">Loading</p>
+		</div>
+	</div>
+	<div v-if="loaded && notEnoughData" style="height: 100vh;" class="d-flex justify-content-center align-items-center">
+		<div class="text-center">
+			<p class="display-4">Oops!</p>
+			<p class="h3 font-weight-light py-3">We don't have enough data to display your <span class="font-weight-bold">#my2020</span>.</p>
+			<p class="mb-0 h5 font-weight-light">We hope to see you next year!</p>
+		</div>
+	</div>
+	<div v-if="loaded && !notEnoughData" class="d-flex justify-content-center align-items-center" style="width:100%;height:100vh;min-height:500px; padding: 0 15px;">
+
+		<div v-if="page == 1" class="text-center">
+			<p class="h1 font-weight-light">Hello {{user.username}}!</p>
+			<p class="h1 py-4">Your 2020 on Pixelfed.</p>
+			<p class="h4 font-weight-light mb-0 animate__animated animate__bounceInDown">Use the buttons below to navigate.</p>
+		</div>
+
+		<div v-if="page == 2" class="text-center mw-500">
+			<p class="display-4">User #<span class="font-weight-bold">{{stats.account.user_id}}</span></p>
+			<p class="h3 font-weight-light mb-0">You joined Pixelfed on {{stats.account.created_at}}</p>
+		</div>
+
+		<div v-if="page == 3" class="text-center mw-500">
+			<p class="display-4">You created <span class="font-weight-bold">{{stats.account.posts_count}}</span> posts</p>
+			<p class="h3 font-weight-light mb-0">The average user created <span class="font-weight-bold">{{stats.average.posts}}</span> posts this year.</p>
+		</div>
+
+		<div v-if="page == 4" class="text-center mw-500">
+			<p class="display-4">You liked <span class="font-weight-bold">{{stats.account.likes_count}}</span> posts</p>
+			<p class="h3 font-weight-light mb-0">The average user liked <span class="font-weight-bold">{{stats.average.likes}}</span> posts this year.</p>
+		</div>
+
+		<div v-if="page == 5" class="text-center mw-500">
+			<div v-if="stats.account.most_popular">
+				<p class="h1 font-weight-light mb-0 text-break md-line-height">Your most popular post of 2020 was created on <span class="font-weight-bold">{{stats.account.most_popular.created_at}}</span> with <span class="font-weight-bold">{{stats.account.most_popular.likes_count}}</span> likes.</p>
+				<p class="mt-4 mb-0">
+					<a class="btn btn-outline-light btn-lg btn-block rounded-pill" :href="stats.account.most_popular.url">View Post</a>
+				</p>
+			</div>
+			<div v-else>
+				<p class="h1 font-weight-light mb-0 text-break md-line-height">The most popular post of 2020 was created by <span class="font-weight-bold">{{stats.popular.post.username}}</span> on <span class="font-weight-bold">{{stats.popular.post.created_at}}</span> with <span class="font-weight-bold">{{stats.popular.post.likes_count}}</span> likes.</p>
+				<p class="mt-4 mb-0">
+					<a class="btn btn-outline-light btn-lg btn-block rounded-pill" :href="stats.popular.post.url">View Post</a>
+				</p>
+			</div>
+		</div>
+
+		<div v-if="page == 6" class="text-center mw-500">
+			<p class="display-4"><span class="font-weight-bold">{{stats.account.followers_this_year}}</span> New Followers</p>
+			<p class="h3 font-weight-light mb-0">You followed <span class="font-weight-bold">{{stats.account.followed_this_year}}</span> accounts this year!</p>
+		</div>
+
+		<div v-if="page == 7" class="text-center mw-500">
+			<div v-if="stats.account.hashtag">
+				<p class="h1 text-break">Your favourite hashtag was <span class="font-weight-bold">#{{stats.account.hashtag.name}}</span>.</p>
+				<p class="h3 font-weight-light mb-0">You used it <span class="font-weight-bold">{{stats.account.hashtag.count}}</span> times!</p>
+			</div>
+			<div v-else>
+				<p class="h1 text-break">The most popular hashtag was <span class="font-weight-bold">#{{stats.popular.hashtag.name}}</span></p>
+				<p class="h3 font-weight-light mb-0">It was used <span class="font-weight-bold">{{stats.popular.hashtag.count}}</span> times!</p>
+			</div>
+		</div>
+
+		<div v-if="page == 8" class="text-center mw-500">
+			<p class="display-4">You tagged <span class="font-weight-bold">{{stats.account.places_total}}</span> places.</p>
+			<p v-if="stats.account.places_total" class="h3 font-weight-light mb-0">You tagged <span class="font-weight-bold">{{stats.account.places.name}}</span> a total of <span class="font-weight-bold">{{stats.account.places.count}}</span> times!</p>
+			<p v-else class="h3 font-weight-light mb-0">The most tagged place was <span class="font-weight-bold">{{stats.popular.places.name}}</span> that was tagged a total of <span class="font-weight-bold">{{stats.popular.places.count}}</span> times!</p>
+		</div>
+
+		<div v-if="page == 9" class="text-center">
+			<p class="display-4">Happy 2021!</p>
+			<p class="h3 font-weight-light mb-0">We wish you the best in the new year.</p>
+		</div>
+
+	</div>
+	<div v-if="loaded" class="fixed-top">
+		<p class="text-center mt-3 d-flex justify-content-center align-items-center mb-0">
+			<img src="/img/pixelfed-icon-grey.svg" width="60" height="60">
+			<span class="text-light font-weight-bold ml-3" style="font-size: 22px;">#my2020</span>
+		</p>
+	</div>
+	<div v-if="loaded" class="fixed-bottom">
+		<p class="text-center">
+			<a v-if="!notEnoughData" :class="prevClass()" href="#" @click.prevent="prevPage()" :disabled="page == 1"><i class="fas fa-chevron-left"></i> Back</a>
+			<a class="btn btn-outline-light rounded-pill mx-3" href="/">Back to Pixelfed</a>
+			<a v-if="!notEnoughData" :class="nextClass()" href="#" @click.prevent="nextPage()">Next <i class="fas fa-chevron-right"></i></a>
+		</p>
+	</div>
+</div>
+</template>
+
+<style type="text/css" scoped>
+	.md-line-height {
+		line-height: 1.65 !important;
+	}
+	.mw-500 {
+		max-width: 500px;
+	}
+</style>
+
+<script type="text/javascript">
+	
+export default {
+	data() {
+		return {
+			config: window.App.config,
+			user: {},
+			loggedIn: false,
+			loaded: false,
+			page: 1,
+			stats: [],
+			notEnoughData: false,
+			reportedView: false
+		}
+	},
+
+	mounted() {
+		let u = new URLSearchParams(window.location.search);
+		if( u.has('v') && 
+			u.has('ned') && 
+			u.has('sl') && 
+			u.get('v') == 20 && 
+			u.get('sl') >= 1 && 
+			u.get('sl') <= 9
+		) {
+			if(u.get('ned') == 0) {
+				this.page = u.get('sl');
+			} else {
+				this.notEnoughData = true;
+			}
+		}
+
+		axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+		.then(res => {
+			this.user = res.data;
+			window._sharedData.curUser = res.data;
+		});
+
+		this.fetchData();
+	}, 
+
+	updated() {
+	},
+
+	methods: {
+		fetchData() {
+			axios.get('/api/pixelfed/v2/seasonal/yir')
+			.then(res => {
+				this.stats = res.data;
+				this.loaded = true;
+				this.shortcuts();
+			})
+		},
+
+		nextPage() {
+			if(this.page == 9) {
+				return;
+			}
+
+			if(this.page == 8) {
+				axios.post('/api/pixelfed/v2/seasonal/yir', {
+					'profile_id' : this.user.profile_id
+				})
+			}
+			++this.page;
+			window.history.pushState({}, {}, '/i/my2020?v=20&ned=0&sl=' + this.page);
+		},
+
+		prevPage() {
+			if(this.page == 1) {
+				return;
+			}
+			--this.page;
+			if(this.page == 1) {
+				window.history.pushState({}, {}, '/i/my2020');
+			} else {
+				window.history.pushState({}, {}, '/i/my2020?v=20&ned=0&sl=' + this.page);
+			}
+		},
+
+		prevClass() {
+			return this.page == 1
+				? 'btn btn-outline-muted rounded-pill'
+				: 'btn btn-outline-light rounded-pill';
+		},
+
+		nextClass() {
+			return this.page == 9
+				? 'btn btn-outline-muted rounded-pill'
+				: 'btn btn-outline-light rounded-pill';
+		},
+
+		dateFormat(d) {
+		},
+
+		shortcuts() {
+			let self = this;
+			window.addEventListener("keydown", function(event) {
+				if (event.defaultPrevented) {
+					return;
+				}
+
+				switch(event.code) {
+					case "KeyA":
+					case "ArrowLeft":
+					self.prevPage();
+					break;
+					case "KeyD":
+					case "ArrowRight":
+					self.nextPage();
+					break;
+				}
+
+				event.preventDefault();
+				}, true);
+		}
+	}
+}
+
+</script>

+ 4 - 0
resources/assets/js/my2020.js

@@ -0,0 +1,4 @@
+Vue.component(
+	'my-yearreview',
+	require('./components/My2020.vue').default
+);

+ 10 - 0
resources/views/account/yir.blade.php

@@ -0,0 +1,10 @@
+@extends('layouts.blank')
+
+@section('content')
+<my-yearreview></my-yearreview>
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{mix('js/my2020.js')}}"></script>
+	<script type="text/javascript">App.boot();</script>
+@endpush