Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/Http/Controllers/Admin/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Models\Gallery;
use App\Models\Item;
use App\Models\ItemView;
use App\Models\User;
use Inertia\Inertia;
use Inertia\Response;
Expand All @@ -31,14 +32,35 @@ public function __invoke(): Response
'created_at' => $g->created_at?->toIso8601String(),
]);

$topViewedItems = Item::with('gallery')
->withCount('views')
->orderByDesc('views_count')
->take(8)
->get()
->filter(fn (Item $i) => $i->views_count > 0)
->values()
->map(fn (Item $i) => [
'id' => $i->id,
'short_code' => $i->short_code,
'kind' => $i->kind,
'thumb_url' => $i->thumbUrl(),
'stats_url' => route('admin.items.stats', $i->id),
'gallery_name' => $i->gallery?->name,
'gallery_id' => $i->gallery?->id,
'views_count' => (int) $i->views_count,
]);

return Inertia::render('admin/dashboard', [
'stats' => [
'galleries' => Gallery::count(),
'items' => Item::count(),
'users' => User::count(),
'views_total' => ItemView::count(),
'views_last_7d' => ItemView::where('created_at', '>=', now()->subDays(7))->count(),
],
'recentGalleries' => $recentGalleries,
'recentItems' => $recentItems,
'topViewedItems' => $topViewedItems,
]);
}
}
10 changes: 7 additions & 3 deletions app/Http/Controllers/Admin/GalleryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ public function store(Request $request): RedirectResponse

public function show(Gallery $gallery): Response
{
$gallery->load(['items' => fn ($q) => $q->latest()]);
$gallery->load(['items' => fn ($q) => $q->withCount('views')->latest()]);
$viewsTotal = $gallery->views()->count();

return Inertia::render('admin/galleries/show', [
'gallery' => $this->serializeGallery($gallery, withToken: true),
'gallery' => $this->serializeGallery($gallery, withToken: true, viewsTotal: $viewsTotal),
'items' => $gallery->items->map(fn ($i) => [
'id' => $i->id,
'short_code' => $i->short_code,
Expand All @@ -58,6 +59,8 @@ public function show(Gallery $gallery): Response
'viewer_url' => $i->viewerUrl(),
'original_name' => $i->original_name,
'created_at' => $i->created_at?->toIso8601String(),
'views_count' => (int) ($i->views_count ?? 0),
'stats_url' => route('admin.items.stats', $i->id),
]),
'upload_endpoint' => url("/api/galleries/{$gallery->slug}/upload"),
]);
Expand Down Expand Up @@ -99,7 +102,7 @@ public function rotateToken(Gallery $gallery): RedirectResponse
return back()->with('success', 'API token rotated.');
}

private function serializeGallery(Gallery $gallery, bool $withToken = false): array
private function serializeGallery(Gallery $gallery, bool $withToken = false, ?int $viewsTotal = null): array
{
return [
'id' => $gallery->id,
Expand All @@ -114,6 +117,7 @@ private function serializeGallery(Gallery $gallery, bool $withToken = false): ar
'public_url' => route('gallery.show', $gallery->slug),
'api_token' => $withToken ? $gallery->api_token : null,
'created_at' => $gallery->created_at?->toIso8601String(),
'views_total' => $viewsTotal,
];
}

Expand Down
177 changes: 177 additions & 0 deletions app/Http/Controllers/Admin/ItemStatsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Item;
use App\Models\ItemView;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class ItemStatsController extends Controller
{
public function __invoke(Request $request, Item $item): Response
{
$item->load('gallery');

$total = ItemView::where('item_id', $item->id)->count();
$last7d = ItemView::where('item_id', $item->id)
->where('created_at', '>=', now()->subDays(7))
->count();

$timeline = $this->dailyTimeline($item->id, 30);
$topCountries = $this->topCountries($item->id);
$topUserAgents = $this->topUserAgents($item->id);
$recent = $this->recentViews($item->id);

return Inertia::render('admin/items/stats', [
'item' => [
'id' => $item->id,
'short_code' => $item->short_code,
'kind' => $item->kind,
'mime' => $item->mime,
'thumb_url' => $item->thumbUrl(),
'viewer_url' => $item->viewerUrl(),
'original_name' => $item->original_name,
],
'gallery' => [
'id' => $item->gallery->id,
'name' => $item->gallery->name,
'slug' => $item->gallery->slug,
],
'totals' => [
'all_time' => $total,
'last_7d' => $last7d,
],
'timeline' => $timeline,
'top_countries' => $topCountries,
'top_user_agents' => $topUserAgents,
'recent_views' => $recent,
]);
}

private function dailyTimeline(int $itemId, int $days): array
{
$since = now()->subDays($days - 1)->startOfDay();

$rows = ItemView::query()
->selectRaw('DATE(created_at) as day, COUNT(*) as count')
->where('item_id', $itemId)
->where('created_at', '>=', $since)
->groupBy('day')
->pluck('count', 'day');

$out = [];
for ($i = 0; $i < $days; $i++) {
$day = $since->copy()->addDays($i)->toDateString();
$out[] = [
'date' => $day,
'count' => (int) ($rows[$day] ?? 0),
];
}

return $out;
}

private function topCountries(int $itemId): array
{
return ItemView::query()
->selectRaw('country_code, country_name, COUNT(*) as count')
->where('item_id', $itemId)
->whereNotNull('country_code')
->groupBy('country_code', 'country_name')
->orderByDesc('count')
->limit(10)
->get()
->map(fn ($r) => [
'country_code' => $r->country_code,
'country_name' => $r->country_name,
'count' => (int) $r->count,
])
->all();
}

private function topUserAgents(int $itemId): array
{
$rows = ItemView::query()
->select('user_agent')
->where('item_id', $itemId)
->whereNotNull('user_agent')
->get();

$buckets = [];
foreach ($rows as $row) {
$label = $this->summarizeUserAgent($row->user_agent);
$buckets[$label] = ($buckets[$label] ?? 0) + 1;
}
arsort($buckets);

$out = [];
foreach (array_slice($buckets, 0, 10, true) as $label => $count) {
$out[] = ['label' => $label, 'count' => $count];
}

return $out;
}

private function recentViews(int $itemId): array
{
$paginator = ItemView::query()
->where('item_id', $itemId)
->latest()
->simplePaginate(25)
->withQueryString();

return [
'data' => collect($paginator->items())->map(fn (ItemView $v) => [
'id' => $v->id,
'created_at' => $v->created_at?->toIso8601String(),
'country_code' => $v->country_code,
'country_name' => $v->country_name,
'city' => $v->city,
'region' => $v->region,
'user_agent_summary' => $this->summarizeUserAgent($v->user_agent),
'user_agent' => $v->user_agent,
'referer' => $v->referer,
])->all(),
'prev_page_url' => $paginator->previousPageUrl(),
'next_page_url' => $paginator->nextPageUrl(),
'current_page' => $paginator->currentPage(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
];
}

private function summarizeUserAgent(?string $ua): string
{
if (! $ua) {
return 'Unknown';
}

$ua = trim($ua);

$browser = match (true) {
str_contains($ua, 'Edg/') => 'Edge',
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
str_contains($ua, 'Firefox/') => 'Firefox',
str_contains($ua, 'Chrome/') => 'Chrome',
str_contains($ua, 'Safari/') => 'Safari',
str_contains($ua, 'curl/') => 'curl',
str_contains($ua, 'wget') => 'wget',
str_contains($ua, 'bot') || str_contains($ua, 'Bot') => 'Bot',
default => 'Other',
};

$os = match (true) {
str_contains($ua, 'Windows') => 'Windows',
str_contains($ua, 'Mac OS X') || str_contains($ua, 'Macintosh') => 'macOS',
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
str_contains($ua, 'Android') => 'Android',
str_contains($ua, 'Linux') => 'Linux',
default => 'Unknown OS',
};

return "{$browser} · {$os}";
}
}
31 changes: 31 additions & 0 deletions app/Http/Controllers/GalleryViewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@

namespace App\Http\Controllers;

use App\Jobs\GeolocateItemView;
use App\Models\Gallery;
use App\Models\Item;
use App\Models\ItemView;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;

class GalleryViewController extends Controller
{
Expand Down Expand Up @@ -46,6 +50,7 @@ public function showItem(Request $request, string $code): \Inertia\Response
{
$item = Item::where('short_code', $code)->with('gallery', 'comments.user')->firstOrFail();
$this->authorizeAccess($item->gallery);
$this->recordView($request, $item);

return Inertia::render('viewer/item', [
'gallery' => [
Expand Down Expand Up @@ -96,6 +101,32 @@ public function thumb(Request $request, string $code): StreamedResponse|Response
return $this->streamFromDisk($item->disk, $path, $mime);
}

private function recordView(Request $request, Item $item): void
{
$user = Auth::user();
if ($user && $user->isAdmin()) {
return;
}

try {
$view = ItemView::create([
'item_id' => $item->id,
'gallery_id' => $item->gallery_id,
'viewer_user_id' => $user?->id,
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 1000),
'referer' => substr((string) $request->headers->get('referer'), 0, 255) ?: null,
]);

GeolocateItemView::dispatch($view->id);
} catch (Throwable $e) {
Log::warning('Failed to record item view', [
'item_id' => $item->id,
'error' => $e->getMessage(),
]);
}
}

private function authorizeAccess(Gallery $gallery): void
{
if ($gallery->isPublic()) {
Expand Down
Loading
Loading