From bd47b69c7b92100fb5f8993a4664b8ebf68f823b Mon Sep 17 00:00:00 2001
From: Saeed Vaziry
Date: Tue, 19 May 2026 16:32:37 +0200
Subject: [PATCH 1/2] Add item view analytics
- Track non-admin item views and surface totals, recent activity, top items, countries, and clients in admin.
- Add per-item stats pages so admins can understand gallery engagement without counting their own visits.
Risk: view geolocation depends on the queued freeipapi lookup.
---
.../Controllers/Admin/DashboardController.php | 22 +
.../Controllers/Admin/GalleryController.php | 10 +-
.../Controllers/Admin/ItemStatsController.php | 177 ++++++++
.../Controllers/GalleryViewController.php | 31 ++
app/Jobs/GeolocateItemView.php | 81 ++++
app/Models/Gallery.php | 5 +
app/Models/Item.php | 5 +
app/Models/ItemView.php | 46 ++
database/factories/ItemViewFactory.php | 35 ++
...6_05_19_000004_create_item_views_table.php | 38 ++
database/seeders/ItemViewsSeeder.php | 129 ++++++
resources/js/pages/admin/dashboard.tsx | 145 +++++--
resources/js/pages/admin/galleries/show.tsx | 34 +-
resources/js/pages/admin/items/stats.tsx | 403 ++++++++++++++++++
routes/web.php | 2 +
tests/Feature/GeolocateItemViewJobTest.php | 110 +++++
tests/Feature/ItemStatsControllerTest.php | 119 ++++++
tests/Feature/ItemViewTrackingTest.php | 62 +++
18 files changed, 1418 insertions(+), 36 deletions(-)
create mode 100644 app/Http/Controllers/Admin/ItemStatsController.php
create mode 100644 app/Jobs/GeolocateItemView.php
create mode 100644 app/Models/ItemView.php
create mode 100644 database/factories/ItemViewFactory.php
create mode 100644 database/migrations/2026_05_19_000004_create_item_views_table.php
create mode 100644 database/seeders/ItemViewsSeeder.php
create mode 100644 resources/js/pages/admin/items/stats.tsx
create mode 100644 tests/Feature/GeolocateItemViewJobTest.php
create mode 100644 tests/Feature/ItemStatsControllerTest.php
create mode 100644 tests/Feature/ItemViewTrackingTest.php
diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php
index aca92d1..afab05b 100644
--- a/app/Http/Controllers/Admin/DashboardController.php
+++ b/app/Http/Controllers/Admin/DashboardController.php
@@ -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;
@@ -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,
]);
}
}
diff --git a/app/Http/Controllers/Admin/GalleryController.php b/app/Http/Controllers/Admin/GalleryController.php
index ccf61d9..1f5bd9d 100644
--- a/app/Http/Controllers/Admin/GalleryController.php
+++ b/app/Http/Controllers/Admin/GalleryController.php
@@ -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,
@@ -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"),
]);
@@ -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,
@@ -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,
];
}
diff --git a/app/Http/Controllers/Admin/ItemStatsController.php b/app/Http/Controllers/Admin/ItemStatsController.php
new file mode 100644
index 0000000..20bf83c
--- /dev/null
+++ b/app/Http/Controllers/Admin/ItemStatsController.php
@@ -0,0 +1,177 @@
+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}";
+ }
+}
diff --git a/app/Http/Controllers/GalleryViewController.php b/app/Http/Controllers/GalleryViewController.php
index 270ea65..210d6f9 100644
--- a/app/Http/Controllers/GalleryViewController.php
+++ b/app/Http/Controllers/GalleryViewController.php
@@ -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
{
@@ -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' => [
@@ -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()) {
diff --git a/app/Jobs/GeolocateItemView.php b/app/Jobs/GeolocateItemView.php
new file mode 100644
index 0000000..eb4590e
--- /dev/null
+++ b/app/Jobs/GeolocateItemView.php
@@ -0,0 +1,81 @@
+viewId);
+ if (! $view || $view->geo_status !== ItemView::GEO_PENDING) {
+ return;
+ }
+
+ $ip = $view->ip_address;
+ if (! $ip || ! $this->isPublicIp($ip)) {
+ $view->update([
+ 'geo_status' => ItemView::GEO_DONE,
+ 'ip_address' => null,
+ ]);
+
+ return;
+ }
+
+ try {
+ $response = Http::timeout(5)
+ ->acceptJson()
+ ->get("https://free.freeipapi.com/api/json/{$ip}");
+ } catch (Throwable $e) {
+ Log::warning('freeipapi lookup failed', ['ip' => $ip, 'error' => $e->getMessage()]);
+ $view->update(['geo_status' => ItemView::GEO_FAILED]);
+
+ return;
+ }
+
+ if (! $response->successful()) {
+ $view->update(['geo_status' => ItemView::GEO_FAILED]);
+
+ return;
+ }
+
+ $data = $response->json() ?? [];
+
+ $view->update([
+ 'country_code' => $data['countryCode'] ?? null,
+ 'country_name' => $data['countryName'] ?? null,
+ 'region' => $data['regionName'] ?? null,
+ 'city' => $data['cityName'] ?? null,
+ 'latitude' => isset($data['latitude']) ? (float) $data['latitude'] : null,
+ 'longitude' => isset($data['longitude']) ? (float) $data['longitude'] : null,
+ 'geo_status' => ItemView::GEO_DONE,
+ 'ip_address' => null,
+ ]);
+ }
+
+ private function isPublicIp(string $ip): bool
+ {
+ return (bool) filter_var(
+ $ip,
+ FILTER_VALIDATE_IP,
+ FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
+ );
+ }
+}
diff --git a/app/Models/Gallery.php b/app/Models/Gallery.php
index fbcb218..f61d3c0 100644
--- a/app/Models/Gallery.php
+++ b/app/Models/Gallery.php
@@ -35,6 +35,11 @@ public function items(): HasMany
return $this->hasMany(Item::class);
}
+ public function views(): HasMany
+ {
+ return $this->hasMany(ItemView::class);
+ }
+
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
diff --git a/app/Models/Item.php b/app/Models/Item.php
index 8bdce0a..a3413c6 100644
--- a/app/Models/Item.php
+++ b/app/Models/Item.php
@@ -27,6 +27,11 @@ public function comments(): HasMany
return $this->hasMany(Comment::class)->latest();
}
+ public function views(): HasMany
+ {
+ return $this->hasMany(ItemView::class);
+ }
+
public static function generateShortCode(): string
{
do {
diff --git a/app/Models/ItemView.php b/app/Models/ItemView.php
new file mode 100644
index 0000000..48c7fce
--- /dev/null
+++ b/app/Models/ItemView.php
@@ -0,0 +1,46 @@
+ 'float',
+ 'longitude' => 'float',
+ ];
+ }
+
+ public function item(): BelongsTo
+ {
+ return $this->belongsTo(Item::class);
+ }
+
+ public function gallery(): BelongsTo
+ {
+ return $this->belongsTo(Gallery::class);
+ }
+
+ public function viewer(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'viewer_user_id');
+ }
+}
diff --git a/database/factories/ItemViewFactory.php b/database/factories/ItemViewFactory.php
new file mode 100644
index 0000000..6ac11dc
--- /dev/null
+++ b/database/factories/ItemViewFactory.php
@@ -0,0 +1,35 @@
+
+ */
+class ItemViewFactory extends Factory
+{
+ protected $model = ItemView::class;
+
+ public function definition(): array
+ {
+ return [
+ 'item_id' => Item::factory(),
+ 'gallery_id' => Gallery::factory(),
+ 'viewer_user_id' => null,
+ 'ip_address' => null,
+ 'user_agent' => 'Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0',
+ 'referer' => null,
+ 'country_code' => 'US',
+ 'country_name' => 'United States',
+ 'region' => 'California',
+ 'city' => 'San Francisco',
+ 'latitude' => 37.7749,
+ 'longitude' => -122.4194,
+ 'geo_status' => ItemView::GEO_DONE,
+ ];
+ }
+}
diff --git a/database/migrations/2026_05_19_000004_create_item_views_table.php b/database/migrations/2026_05_19_000004_create_item_views_table.php
new file mode 100644
index 0000000..b7e4666
--- /dev/null
+++ b/database/migrations/2026_05_19_000004_create_item_views_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->foreignId('item_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('gallery_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('viewer_user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('user_agent')->nullable();
+ $table->string('referer')->nullable();
+ $table->string('country_code', 2)->nullable();
+ $table->string('country_name')->nullable();
+ $table->string('region')->nullable();
+ $table->string('city')->nullable();
+ $table->decimal('latitude', 10, 6)->nullable();
+ $table->decimal('longitude', 10, 6)->nullable();
+ $table->string('geo_status', 16)->default('pending');
+ $table->timestamps();
+
+ $table->index(['item_id', 'created_at']);
+ $table->index(['gallery_id', 'created_at']);
+ $table->index('geo_status');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('item_views');
+ }
+};
diff --git a/database/seeders/ItemViewsSeeder.php b/database/seeders/ItemViewsSeeder.php
new file mode 100644
index 0000000..06c4911
--- /dev/null
+++ b/database/seeders/ItemViewsSeeder.php
@@ -0,0 +1,129 @@
+isEmpty()) {
+ $this->command?->warn('No items found. Upload at least one item first.');
+
+ return;
+ }
+
+ $totalCreated = 0;
+
+ foreach ($items as $item) {
+ $viewCount = random_int(15, 80);
+
+ for ($i = 0; $i < $viewCount; $i++) {
+ $location = self::LOCATIONS[array_rand(self::LOCATIONS)];
+ $ua = self::USER_AGENTS[array_rand(self::USER_AGENTS)];
+ $referer = self::REFERERS[array_rand(self::REFERERS)];
+
+ $daysAgo = $this->weightedRecentDay();
+ $createdAt = Carbon::now()
+ ->subDays($daysAgo)
+ ->subMinutes(random_int(0, 1439));
+
+ ItemView::create([
+ 'item_id' => $item->id,
+ 'gallery_id' => $item->gallery_id,
+ 'viewer_user_id' => null,
+ 'ip_address' => null,
+ 'user_agent' => $ua,
+ 'referer' => $referer,
+ 'country_code' => $location[0],
+ 'country_name' => $location[1],
+ 'region' => $location[2],
+ 'city' => $location[3],
+ 'latitude' => $location[4],
+ 'longitude' => $location[5],
+ 'geo_status' => ItemView::GEO_DONE,
+ 'created_at' => $createdAt,
+ 'updated_at' => $createdAt,
+ ]);
+
+ $totalCreated++;
+ }
+ }
+
+ $this->command?->info("Seeded {$totalCreated} item views across {$items->count()} item(s).");
+ }
+
+ /**
+ * Bias recent days more heavily, but keep some tail back to 30 days.
+ */
+ private function weightedRecentDay(): int
+ {
+ $r = mt_rand() / mt_getrandmax();
+
+ if ($r < 0.4) {
+ return random_int(0, 2);
+ }
+ if ($r < 0.7) {
+ return random_int(3, 6);
+ }
+ if ($r < 0.9) {
+ return random_int(7, 14);
+ }
+
+ return random_int(15, 29);
+ }
+}
diff --git a/resources/js/pages/admin/dashboard.tsx b/resources/js/pages/admin/dashboard.tsx
index 1306cd5..e285b0a 100644
--- a/resources/js/pages/admin/dashboard.tsx
+++ b/resources/js/pages/admin/dashboard.tsx
@@ -1,5 +1,5 @@
import { Head, Link } from '@inertiajs/react';
-import { ImageIcon, PlayCircle } from 'lucide-react';
+import { Eye, ImageIcon, PlayCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +10,13 @@ import {
CardTitle,
} from '@/components/ui/card';
-type Stats = { galleries: number; items: number; users: number };
+type Stats = {
+ galleries: number;
+ items: number;
+ users: number;
+ views_total: number;
+ views_last_7d: number;
+};
type RecentGallery = {
id: number;
name: string;
@@ -28,14 +34,27 @@ type RecentItem = {
gallery_slug?: string;
};
+type TopViewedItem = {
+ id: number;
+ short_code: string;
+ kind: 'image' | 'video';
+ thumb_url: string | null;
+ stats_url: string;
+ gallery_name?: string;
+ gallery_id?: number;
+ views_count: number;
+};
+
export default function AdminDashboard({
stats,
recentGalleries,
recentItems,
+ topViewedItems,
}: {
stats: Stats;
recentGalleries: RecentGallery[];
recentItems: RecentItem[];
+ topViewedItems: TopViewedItem[];
}) {
return (
<>
@@ -50,11 +69,13 @@ export default function AdminDashboard({
-
+
{[
{ label: 'Galleries', value: stats.galleries },
{ label: 'Uploaded items', value: stats.items },
{ label: 'Users', value: stats.users },
+ { label: 'Total views', value: stats.views_total },
+ { label: 'Views (last 7d)', value: stats.views_last_7d },
].map((s) => (
@@ -126,48 +147,112 @@ export default function AdminDashboard({
- Recent uploads
+ Most viewed items
- The last few items added to any gallery.
+ Top items by recorded views (admin views excluded).
- {recentItems.length === 0 ? (
+ {topViewedItems.length === 0 ? (
- No uploads yet.
+ No views recorded yet.
) : (
-
- {recentItems.map((i) => (
-
+ {topViewedItems.map((i) => (
+
- {i.kind === 'image' &&
- i.thumb_url ? (
-
- ) : (
-
- {i.kind === 'video' ? (
-
- ) : (
-
- )}
+
+ {i.kind === 'image' &&
+ i.thumb_url ? (
+

+ ) : (
+
+ {i.kind === 'video' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {i.short_code}
+
+
+ {i.gallery_name ?? '—'}
- )}
-
+
+
+
+ {i.views_count}
+
+
))}
-
+
)}
+
+
+
+ Recent uploads
+
+ The last few items added to any gallery.
+
+
+
+ {recentItems.length === 0 ? (
+
+ No uploads yet.
+
+ ) : (
+
+ {recentItems.map((i) => (
+
+ {i.kind === 'image' && i.thumb_url ? (
+

+ ) : (
+
+ {i.kind === 'video' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+
>
);
diff --git a/resources/js/pages/admin/galleries/show.tsx b/resources/js/pages/admin/galleries/show.tsx
index 2ab0c04..96be31e 100644
--- a/resources/js/pages/admin/galleries/show.tsx
+++ b/resources/js/pages/admin/galleries/show.tsx
@@ -1,5 +1,6 @@
import { Head, Link, router } from '@inertiajs/react';
import {
+ BarChart3,
Copy,
Eye,
EyeOff,
@@ -43,6 +44,7 @@ type Gallery = {
comments_enabled: boolean;
public_url: string;
api_token: string;
+ views_total: number | null;
};
type Item = {
@@ -56,6 +58,8 @@ type Item = {
viewer_url: string;
original_name: string | null;
created_at: string;
+ views_count: number;
+ stats_url: string;
};
export default function GalleryShow({
@@ -278,6 +282,12 @@ export default function GalleryShow({
{items.length}
+
+
+ Total views
+
+ {gallery.views_total ?? 0}
+
@@ -332,7 +342,18 @@ function ItemTile({ item }: { item: Item }) {
)}
-
+
-
-
{item.short_code}
+
+ {item.short_code}
+
+
+ {item.views_count}
+
);
diff --git a/resources/js/pages/admin/items/stats.tsx b/resources/js/pages/admin/items/stats.tsx
new file mode 100644
index 0000000..2b95479
--- /dev/null
+++ b/resources/js/pages/admin/items/stats.tsx
@@ -0,0 +1,403 @@
+import { Head, Link } from '@inertiajs/react';
+import {
+ ArrowLeft,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ PlayCircle,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+
+type Item = {
+ id: number;
+ short_code: string;
+ kind: 'image' | 'video';
+ mime: string;
+ thumb_url: string | null;
+ viewer_url: string;
+ original_name: string | null;
+};
+
+type Gallery = {
+ id: number;
+ name: string;
+ slug: string;
+};
+
+type Totals = {
+ all_time: number;
+ last_7d: number;
+};
+
+type TimelinePoint = {
+ date: string;
+ count: number;
+};
+
+type CountryRow = {
+ country_code: string | null;
+ country_name: string | null;
+ count: number;
+};
+
+type UaRow = {
+ label: string;
+ count: number;
+};
+
+type RecentView = {
+ id: number;
+ created_at: string | null;
+ country_code: string | null;
+ country_name: string | null;
+ city: string | null;
+ region: string | null;
+ user_agent_summary: string;
+ user_agent: string | null;
+ referer: string | null;
+};
+
+type RecentViewsPage = {
+ data: RecentView[];
+ prev_page_url: string | null;
+ next_page_url: string | null;
+ current_page: number;
+ from: number | null;
+ to: number | null;
+};
+
+export default function ItemStats({
+ item,
+ gallery,
+ totals,
+ timeline,
+ top_countries,
+ top_user_agents,
+ recent_views,
+}: {
+ item: Item;
+ gallery: Gallery;
+ totals: Totals;
+ timeline: TimelinePoint[];
+ top_countries: CountryRow[];
+ top_user_agents: UaRow[];
+ recent_views: RecentViewsPage;
+}) {
+ const maxTimeline = Math.max(1, ...timeline.map((t) => t.count));
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {item.kind === 'image' && item.thumb_url ? (
+

+ ) : (
+
+ )}
+
+
+
+ {item.original_name ?? item.short_code}
+
+
+
+ /s/{item.short_code}
+
+ ·
+
+ {gallery.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Views over the last 30 days
+
+ Daily unique view-record counts. Admin views are not
+ tracked.
+
+
+
+
+ {timeline.map((t) => {
+ const h = Math.max(
+ 2,
+ Math.round((t.count / maxTimeline) * 100),
+ );
+ return (
+
+ );
+ })}
+
+
+ {timeline[0]?.date}
+ {timeline[timeline.length - 1]?.date}
+
+
+
+
+
+
+
+ Top countries
+
+ Where viewers came from (resolved via IP).
+
+
+
+ {top_countries.length === 0 ? (
+
+ No geo data yet.
+
+ ) : (
+
+ {top_countries.map((c) => (
+ -
+
+ {c.country_name ?? c.country_code}
+
+
+ {c.count}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ Top user agents
+
+ Browser and OS combinations.
+
+
+
+ {top_user_agents.length === 0 ? (
+
+ No views yet.
+
+ ) : (
+
+ {top_user_agents.map((u) => (
+ -
+
+ {u.label}
+
+
+ {u.count}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Recent views
+
+ All recorded views, newest first.
+
+
+
+ {recent_views.data.length === 0 ? (
+
+ No views yet.
+
+ ) : (
+ <>
+
+
+
+ When
+ Location
+ Client
+ Referer
+
+
+
+ {recent_views.data.map((v) => (
+
+
+ {formatDate(v.created_at)}
+
+
+ {formatLocation(v)}
+
+
+ {v.user_agent_summary}
+
+
+ {v.referer ?? '—'}
+
+
+ ))}
+
+
+
+
+ {recent_views.from ?? 0}–
+ {recent_views.to ?? 0} · page{' '}
+ {recent_views.current_page}
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ >
+ );
+}
+
+function StatCard({ label, value }: { label: string; value: number }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+
+
+ );
+}
+
+function formatDate(iso: string | null): string {
+ if (!iso) return '—';
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+}
+
+function formatLocation(v: RecentView): string {
+ const parts = [v.city, v.region, v.country_name ?? v.country_code].filter(
+ Boolean,
+ );
+ return parts.length ? parts.join(', ') : '—';
+}
diff --git a/routes/web.php b/routes/web.php
index c995903..d82dee3 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -3,6 +3,7 @@
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\GalleryController as AdminGalleryController;
use App\Http\Controllers\Admin\ItemController as AdminItemController;
+use App\Http\Controllers\Admin\ItemStatsController;
use App\Http\Controllers\Admin\UserController as AdminUserController;
use App\Http\Controllers\CommentController;
use App\Http\Controllers\DocsController;
@@ -69,6 +70,7 @@
->name('galleries.rotate-token');
Route::delete('items/{item}', [AdminItemController::class, 'destroy'])->name('items.destroy');
+ Route::get('items/{item}/stats', ItemStatsController::class)->name('items.stats');
Route::resource('users', AdminUserController::class)->except(['create', 'edit', 'show']);
});
diff --git a/tests/Feature/GeolocateItemViewJobTest.php b/tests/Feature/GeolocateItemViewJobTest.php
new file mode 100644
index 0000000..dceec35
--- /dev/null
+++ b/tests/Feature/GeolocateItemViewJobTest.php
@@ -0,0 +1,110 @@
+ Http::response([
+ 'countryCode' => 'NL',
+ 'countryName' => 'Netherlands',
+ 'regionName' => 'North Holland',
+ 'cityName' => 'Amsterdam',
+ 'latitude' => 52.3676,
+ 'longitude' => 4.9041,
+ ], 200),
+ ]);
+
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+ $view = ItemView::create([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'ip_address' => '203.0.113.10',
+ 'user_agent' => 'ua',
+ 'geo_status' => ItemView::GEO_PENDING,
+ ]);
+
+ (new GeolocateItemView($view->id))->handle();
+
+ $view->refresh();
+ expect($view->country_code)->toBe('NL');
+ expect($view->country_name)->toBe('Netherlands');
+ expect($view->city)->toBe('Amsterdam');
+ expect((float) $view->latitude)->toBe(52.3676);
+ expect($view->geo_status)->toBe(ItemView::GEO_DONE);
+ expect($view->ip_address)->toBeNull();
+});
+
+it('marks the view failed when the API errors', function () {
+ Http::fake([
+ 'free.freeipapi.com/api/json/*' => Http::response([], 500),
+ ]);
+
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+ $view = ItemView::create([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'ip_address' => '203.0.113.10',
+ 'geo_status' => ItemView::GEO_PENDING,
+ ]);
+
+ (new GeolocateItemView($view->id))->handle();
+
+ $view->refresh();
+ expect($view->geo_status)->toBe(ItemView::GEO_FAILED);
+ expect($view->country_code)->toBeNull();
+ // IP is preserved on failure for potential retry.
+ expect($view->ip_address)->toBe('203.0.113.10');
+});
+
+it('skips private/loopback IPs without calling the API', function () {
+ Http::fake();
+
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+ $view = ItemView::create([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'ip_address' => '127.0.0.1',
+ 'geo_status' => ItemView::GEO_PENDING,
+ ]);
+
+ (new GeolocateItemView($view->id))->handle();
+
+ Http::assertNothingSent();
+ $view->refresh();
+ expect($view->geo_status)->toBe(ItemView::GEO_DONE);
+ expect($view->country_code)->toBeNull();
+ expect($view->ip_address)->toBeNull();
+});
+
+it('does nothing for views that are no longer pending', function () {
+ Http::fake();
+
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+ $view = ItemView::create([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'ip_address' => '203.0.113.10',
+ 'geo_status' => ItemView::GEO_DONE,
+ ]);
+
+ (new GeolocateItemView($view->id))->handle();
+
+ Http::assertNothingSent();
+ expect($view->fresh()->country_code)->toBeNull();
+});
+
+it('does nothing when the view no longer exists', function () {
+ Http::fake();
+
+ (new GeolocateItemView(999_999))->handle();
+
+ Http::assertNothingSent();
+});
diff --git a/tests/Feature/ItemStatsControllerTest.php b/tests/Feature/ItemStatsControllerTest.php
new file mode 100644
index 0000000..4ee43c8
--- /dev/null
+++ b/tests/Feature/ItemStatsControllerTest.php
@@ -0,0 +1,119 @@
+admin = User::factory()->admin()->create();
+});
+
+it('blocks non-admins from the stats page', function () {
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->get("/admin/items/{$item->id}/stats")
+ ->assertForbidden();
+});
+
+it('redirects guests to login', function () {
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+
+ $this->get("/admin/items/{$item->id}/stats")->assertRedirect('/login');
+});
+
+it('renders totals, top countries, and paginated recent views', function () {
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+
+ // 3 from US, 1 from DE, all "done" geo
+ ItemView::factory()->count(3)->state([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'country_code' => 'US',
+ 'country_name' => 'United States',
+ 'geo_status' => ItemView::GEO_DONE,
+ ])->create();
+ ItemView::factory()->state([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'country_code' => 'DE',
+ 'country_name' => 'Germany',
+ 'geo_status' => ItemView::GEO_DONE,
+ ])->create();
+
+ // 30 more US views to force pagination (page size 25).
+ ItemView::factory()->count(30)->state([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'country_code' => 'US',
+ 'country_name' => 'United States',
+ 'geo_status' => ItemView::GEO_DONE,
+ ])->create();
+
+ $this->actingAs($this->admin)
+ ->get("/admin/items/{$item->id}/stats")
+ ->assertOk()
+ ->assertInertia(
+ fn ($page) => $page
+ ->component('admin/items/stats')
+ ->where('totals.all_time', 34)
+ ->where('top_countries.0.country_code', 'US')
+ ->where('top_countries.0.count', 33)
+ ->where('recent_views.current_page', 1)
+ ->has('recent_views.data', 25)
+ ->where('recent_views.next_page_url', fn ($url) => str_contains((string) $url, 'page=2'))
+ ->where('recent_views.prev_page_url', null)
+ );
+});
+
+it('returns the second page of recent views', function () {
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+ ItemView::factory()->count(30)->state([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'geo_status' => ItemView::GEO_DONE,
+ ])->create();
+
+ $this->actingAs($this->admin)
+ ->get("/admin/items/{$item->id}/stats?page=2")
+ ->assertOk()
+ ->assertInertia(
+ fn ($page) => $page
+ ->where('recent_views.current_page', 2)
+ ->has('recent_views.data', 5)
+ ->where('recent_views.next_page_url', null)
+ ->where('recent_views.prev_page_url', fn ($url) => str_contains((string) $url, 'page=1'))
+ );
+});
+
+it('exposes view counts on the admin dashboard', function () {
+ $gallery = Gallery::factory()->create();
+ $item = Item::factory()->for($gallery)->create();
+ ItemView::factory()->count(2)->state([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'created_at' => now(),
+ ])->create();
+ ItemView::factory()->state([
+ 'item_id' => $item->id,
+ 'gallery_id' => $gallery->id,
+ 'created_at' => now()->subDays(20),
+ ])->create();
+
+ $this->actingAs($this->admin)
+ ->get('/admin')
+ ->assertOk()
+ ->assertInertia(
+ fn ($page) => $page
+ ->where('stats.views_total', 3)
+ ->where('stats.views_last_7d', 2)
+ ->where('topViewedItems.0.id', $item->id)
+ ->where('topViewedItems.0.views_count', 3)
+ );
+});
diff --git a/tests/Feature/ItemViewTrackingTest.php b/tests/Feature/ItemViewTrackingTest.php
new file mode 100644
index 0000000..ce77cd4
--- /dev/null
+++ b/tests/Feature/ItemViewTrackingTest.php
@@ -0,0 +1,62 @@
+admin()->create();
+});
+
+it('records a view when an anonymous visitor loads /s/{code}', function () {
+ Bus::fake();
+ $gallery = Gallery::factory()->create(['visibility' => 'public']);
+ $item = Item::factory()->for($gallery)->create();
+
+ $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.45'])
+ ->withHeader('User-Agent', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0')
+ ->withHeader('Referer', 'https://example.com/page')
+ ->get("/s/{$item->short_code}")
+ ->assertOk();
+
+ expect(ItemView::count())->toBe(1);
+
+ $view = ItemView::first();
+ expect($view->item_id)->toBe($item->id);
+ expect($view->gallery_id)->toBe($gallery->id);
+ expect($view->viewer_user_id)->toBeNull();
+ expect($view->ip_address)->toBe('203.0.113.45');
+ expect($view->user_agent)->toContain('Chrome/124.0');
+ expect($view->referer)->toBe('https://example.com/page');
+ expect($view->geo_status)->toBe(ItemView::GEO_PENDING);
+
+ Bus::assertDispatched(GeolocateItemView::class, fn ($job) => $job->viewId === $view->id);
+});
+
+it('records a view for a signed-in non-admin user', function () {
+ Bus::fake();
+ $gallery = Gallery::factory()->create(['visibility' => 'public']);
+ $item = Item::factory()->for($gallery)->create();
+ $user = User::factory()->create();
+
+ $this->actingAs($user)->get("/s/{$item->short_code}")->assertOk();
+
+ expect(ItemView::count())->toBe(1);
+ expect(ItemView::first()->viewer_user_id)->toBe($user->id);
+ Bus::assertDispatched(GeolocateItemView::class);
+});
+
+it('does NOT record a view when an admin loads the page', function () {
+ Bus::fake();
+ $gallery = Gallery::factory()->create(['visibility' => 'public']);
+ $item = Item::factory()->for($gallery)->create();
+ $admin = User::factory()->admin()->create();
+
+ $this->actingAs($admin)->get("/s/{$item->short_code}")->assertOk();
+
+ expect(ItemView::count())->toBe(0);
+ Bus::assertNotDispatched(GeolocateItemView::class);
+});
From 5c0f1b58d605332dc3941a28537b78d237769fe2 Mon Sep 17 00:00:00 2001
From: Saeed Vaziry
Date: Tue, 19 May 2026 16:36:36 +0200
Subject: [PATCH 2/2] Fix lint
---
resources/js/pages/admin/dashboard.tsx | 8 ++++--
resources/js/pages/admin/items/stats.tsx | 32 ++++++++++++++++++------
2 files changed, 31 insertions(+), 9 deletions(-)
diff --git a/resources/js/pages/admin/dashboard.tsx b/resources/js/pages/admin/dashboard.tsx
index e285b0a..cf2c02b 100644
--- a/resources/js/pages/admin/dashboard.tsx
+++ b/resources/js/pages/admin/dashboard.tsx
@@ -75,7 +75,10 @@ export default function AdminDashboard({
{ label: 'Uploaded items', value: stats.items },
{ label: 'Users', value: stats.users },
{ label: 'Total views', value: stats.views_total },
- { label: 'Views (last 7d)', value: stats.views_last_7d },
+ {
+ label: 'Views (last 7d)',
+ value: stats.views_last_7d,
+ },
].map((s) => (
@@ -149,7 +152,8 @@ export default function AdminDashboard({
Most viewed items
- Top items by recorded views (admin views excluded).
+ Top items by recorded views (admin views
+ excluded).
diff --git a/resources/js/pages/admin/items/stats.tsx b/resources/js/pages/admin/items/stats.tsx
index 2b95479..79a99b2 100644
--- a/resources/js/pages/admin/items/stats.tsx
+++ b/resources/js/pages/admin/items/stats.tsx
@@ -145,7 +145,11 @@ export default function ItemStats({