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..cf2c02b 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,16 @@ 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 +150,113 @@ 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..79a99b2 --- /dev/null +++ b/resources/js/pages/admin/items/stats.tsx @@ -0,0 +1,421 @@ +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.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); +});