Skip to content
3 changes: 3 additions & 0 deletions app/Enums/FeatureFlag.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ enum FeatureFlag: string implements HasLabel
#[DefaultFeatureFlagState(true)]
case UserSettings = 'user_settings';

#[DefaultFeatureFlagState(true)]
case UserStatistics = 'user_statistics';

#[Description('Toggles the user navigation/dropdown in the top navigation, this is only a visual change and does NOT disable the features shown in that dropdown.')]
#[DefaultFeatureFlagState(true)]
case UserNavigation = 'user_navigation';
Expand Down
21 changes: 21 additions & 0 deletions app/Http/Controllers/ClipVoteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
use App\Actions\GenerateVotingQueueAction;
use App\Enums\ClipVoteType;
use App\Enums\Permission;
use App\Http\Requests\VoteChangeRequest;
use App\Http\Resources\Clip\ClipVoteResource;
use App\Models\Clip;
use App\Models\Scopes\ClipPermissionScope;
use App\Models\Vote;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
Expand All @@ -18,6 +20,7 @@
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

#[Middleware('throttle:10,1', only: ['store'])]
#[Middleware('throttle:10,1', only: ['update'])]
class ClipVoteController extends Controller
{
private const string SESSION_QUEUE_KEY = 'CLIP_VOTE_QUEUE';
Expand Down Expand Up @@ -97,6 +100,24 @@ public function store(Request $request): SymfonyResponse
return new JsonResponse($nextClip?->toResource(ClipVoteResource::class));
}

public function update(VoteChangeRequest $request): SymfonyResponse
{
$voteId = $request->integer('voteId');
$voted = $request->boolean('voted');

$vote = Vote::find($voteId);

$vote->update([
'voted' => $voted,
]);

if (! $request->expectsJson()) {
return back(fallback: route('user.statistics.votes'));
}

return new JsonResponse(true);
}

protected function resolveNextClip(Request $request): ?Clip
{
while ($clipId = $this->getNextClipId($request)) {
Expand Down
53 changes: 53 additions & 0 deletions app/Http/Controllers/Statistics/VotesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Statistics;

use App\Http\Controllers\Controller;
use App\Models\Vote;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;

class VotesController extends Controller
{
/**
* Handle the incoming request.
*/
public function __invoke(Request $request): Response|View
{
$allowedVoteToChange = Vote::query()
->where('user_id', $request->user()->id)
->where('created_at', '>=', now()->sub(config('vheart.clips.voting.maximum_change_age')))
->has('clip')
->orderByDesc('created_at')
->orderByDesc('id')
->first() ?? null;

$votedInfos = Vote::query()
->where('user_id', $request->user()->id)
->has('clip')
->with('clip', fn (Relation $relation) => $relation->withAbsoluteVoteCount())
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate(perPage: 32);

if ($request->ajax()) {
return response(
view('statistics.vote-list', [
'allowedVoteToChange' => $allowedVoteToChange,
'votes' => $votedInfos,
])->render(),
headers: [
'X-Next-Page' => $votedInfos->nextPageUrl(),
]);
}

return view('statistics.votes', [
'allowedVoteToChange' => $allowedVoteToChange,
'votes' => $votedInfos,
]);
}
}
49 changes: 49 additions & 0 deletions app/Http/Controllers/StatisticsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use Carbon\CarbonInterval;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;

class StatisticsController extends Controller
{
public function __invoke(Request $request): View
{
$user = $request->user();

$cacheKey = "user.{$user->id}.statistics";
$clipsSubmitted = Cache::remember(
$cacheKey.'.clipsSubmitted',
now()->addMinute(),
fn () => $user
->submittedClips()
->count()
);

$votes = Cache::remember(
$cacheKey.'.votes',
now()->addMinute(),
fn () => $user
->votes()
->count()
);
$votes30Days = Cache::remember(
$cacheKey.'.votes30Days',
now()->addMinute(),
fn () => $user
->votes()
->where('created_at', '>=', now()->sub(CarbonInterval::fromString(('30 days'))))
->count()
);

return view('statistics', [
'clipsSubmitted' => $clipsSubmitted,
'votes' => $votes,
'votes30Days' => $votes30Days,
]);
}
}
62 changes: 62 additions & 0 deletions app/Http/Requests/VoteChangeRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Vote;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;

class VoteChangeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return auth()->check();
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'voteId' => ['bail', 'required', 'integer', 'exists:votes,id'],
'voted' => ['required', 'bool'],
];
}

public function after(): array
{

return [
function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$voteId = $this->integer('voteId');

$allowedVoteToChange = Vote::query()
->where('user_id', $this->user()->id)
->where('created_at', '>=', now()->sub(config('vheart.clips.voting.maximum_change_age')))
->has('clip')
->orderByDesc('created_at')
->orderByDesc('id')
->first() ?? null;

if ($allowedVoteToChange === null || $allowedVoteToChange->id !== $voteId) {

$validator->errors()->add('voteId', __('vote.errors.change_not_allowed'));

return;
}
},
];
}
}
2 changes: 2 additions & 0 deletions config/vheart.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
// as the clip with the most, we can still configure later though if we need to.
// https://www.desmos.com/calculator/pu5hu8bpev
'interaction_boost_exponent' => (int) env('VHEART_CLIPS_VOTING_INTERACTION_BOOST_EXPONENT', 4),

'maximum_change_age' => CarbonInterval::fromString((string) env('VHEART_CLIPS_VOTING_MAXIMUM_CHANGE_AGE', '5 minutes')),
],
'scoring' => [
'jury_weight' => (int) env('VHEART_CLIPS_SCORING_JURY_WEIGHT', 10),
Expand Down
1 change: 1 addition & 0 deletions lang/de/navigation.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@
'team_dashboard' => 'Team Dashboard',
'settings' => 'Einstellungen',
'logout' => 'Abmelden',
'statistics' => 'Statistiken',
];
33 changes: 33 additions & 0 deletions lang/de/user.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,37 @@
'submit' => 'Konto Löschen',
],
],
'statistics' => [
'title' => 'Deine Statistiken',
'heading' => 'Deine Statistiken',
'subheading' => '',
'description' => '',
'stats' => [
'clips-submitted' => [
'title' => 'Eingereichte Clips',
'details' => 'Eingereichte Clips Anzeigen',
],
'votes' => [
'title' => 'Votes',
'details' => 'Votes Anzeigen',
],
'votes-30days' => [
'title' => 'Votes Last 30 Days',
'details' => 'Votes Anzeigen',
],
],
],
'statistics_details' => [
'infinite-loader' => [
'no-more' => 'Keine weiteren :name verfügbar',
'nothing-found' => 'Keine :name gefunden',
'nothing-found-subtext' => 'Gerade sind keine :name verfügbar.',
],
'votes' => [
'name' => 'Votes',
'heading' => 'Deine Votes',
'subheading' => '',
'description' => '',
],
],
];
3 changes: 3 additions & 0 deletions lang/de/vote.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@

return [
'call_to_action' => 'Du möchtest Abstimmen? Klicke Hier!',
'errors' => [
'change_not_allowed' => 'Du bist nicht erlaubt den Vote zu ändern.',
],
];
1 change: 1 addition & 0 deletions lang/en/navigation.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@
'team_dashboard' => 'Team Dashboard',
'settings' => 'Settings',
'logout' => 'Logout',
'statistics' => 'Statistics',
];
33 changes: 33 additions & 0 deletions lang/en/user.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,37 @@
'submit' => 'Delete Account',
],
],
'statistics' => [
'title' => 'Account Statistics',
'heading' => 'Your Statistics',
'subheading' => '',
'description' => '',
'stats' => [
'clips-submitted' => [
'title' => 'Submitted Clips',
'details' => 'View Submitted Clips',
],
'votes' => [
'title' => 'Votes',
'details' => 'View Votes',
],
'votes-30days' => [
'title' => 'Votes Last 30 Days',
'details' => 'View Votes',
],
],
],
'statistics_details' => [
'infinite-loader' => [
'no-more' => 'There are no more :name',
'nothing-found' => 'No :name found',
'nothing-found-subtext' => 'There are no :name available right now.',
],
'votes' => [
'name' => 'Votes',
'heading' => 'Your Votes',
'subheading' => '',
'description' => '',
],
],
];
3 changes: 3 additions & 0 deletions lang/en/vote.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@

return [
'call_to_action' => 'You want to Vote Clips? Click Here!',
'errors' => [
'change_not_allowed' => 'You are not allowed to change this Vote.',
],
];
8 changes: 7 additions & 1 deletion resources/views/components/layout/header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,13 @@ class="hidden size-4 opacity-70 transition-transform duration-200 group-hover:sc
</x-ui.dropdown.item>
@endfeature

@featureAny([FeatureFlag::UserDashboard, FeatureFlag::UserSettings])
@feature(FeatureFlag::UserStatistics)
<x-ui.dropdown.item href="{{ route('user.statistics') }}">
{{ __('navigation.statistics') }}
</x-ui.dropdown.item>
@endfeature

@featureAny([FeatureFlag::UserDashboard, FeatureFlag::UserSettings,FeatureFlag::UserStatistics])
<x-ui.dropdown.separator/>
@endfeatureAny

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@props(['name' => 'Clips'])
<template x-if="isError">
<div class="text-center py-12 space-y-2">
<p class="font-medium h-8">{{ __('user.statistics_details.infinite-loader.loading-error',['name' => $name]) }}</p>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template x-if="loading">
<div class="flex justify-center py-12 w-full">
<x-lucide-loader-2 class="animate-spin size-8 text-accent opacity-50" defer />
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@props(['name' => 'Clips'])
<div class="text-center py-12 space-y-2">
<p class="font-medium">{{ __('user.statistics_details.infinite-loader.no-more',['name' => $name]) }}</p>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@props(['name' => 'Clips'])
<div class="flex flex-col items-center justify-center">
<x-lucide-video-off defer class="size-12 text-muted-foreground mb-4" />
<h2 class="text-lg font-semibold">
{{ __('user.statistics_details.infinite-loader.nothing-found',['name' => $name]) }}
</h2>
<p class="mt-2 text-sm text-muted-foreground text-center max-w-md text-balance">
{{ __('user.statistics_details.infinite-loader.nothing-found-subtext',['name' => $name]) }}
</p>
</div>
Loading