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({
-- No uploads yet. + No views recorded yet.
) : ( -+ No uploads yet. +
+ ) : ( +{item.short_code}
+ {item.short_code}
+
+
+ /s/{item.short_code}
+
+ ·
+
+ {gallery.name}
+
+ + No geo data yet. +
+ ) : ( ++ No views yet. +
+ ) : ( ++ No views yet. +
+ ) : ( + <> +