Add Year in Review feature (mysql only)

This commit is contained in:
Daniel Supernault 2021-01-17 19:50:35 -07:00
parent b00e2b0868
commit f32072a396
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
4 changed files with 469 additions and 9 deletions

View file

@ -4,6 +4,12 @@ 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
{
@ -14,7 +20,219 @@ class SeasonalController extends Controller
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();
}
}

View file

@ -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
resources/assets/js/my2020.js vendored Normal file
View file

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

View file

@ -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