diff --git a/app/Console/Commands/MetricsSnapshotHourlyCommand.php b/app/Console/Commands/MetricsSnapshotHourlyCommand.php new file mode 100644 index 00000000..cf68cb67 --- /dev/null +++ b/app/Console/Commands/MetricsSnapshotHourlyCommand.php @@ -0,0 +1,113 @@ +option('days'); + $chunk = (int) $this->option('chunk'); + $dryRun = (bool) $this->option('dry-run'); + $bucketHour = now()->startOfHour(); + + $this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : '')); + + $snapshotCount = 0; + $skipCount = 0; + + // Query artworks eligible for snapshotting: + // - created within $days OR has a ranking_score above 0 + // First collect eligible IDs, then process in chunks + $eligibleIds = DB::table('artworks') + ->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id') + ->where(function ($q) use ($days) { + $q->where('artworks.created_at', '>=', now()->subDays($days)) + ->orWhere(function ($q2) { + $q2->whereNotNull('s.ranking_score') + ->where('s.ranking_score', '>', 0); + }); + }) + ->whereNull('artworks.deleted_at') + ->where('artworks.is_approved', true) + ->pluck('artworks.id'); + + if ($eligibleIds->isEmpty()) { + $this->info('No eligible artworks found.'); + return self::SUCCESS; + } + + foreach ($eligibleIds->chunk($chunk) as $chunkIds) { + $artworkIds = $chunkIds->values()->all(); + + $stats = DB::table('artwork_stats') + ->whereIn('artwork_id', $artworkIds) + ->get() + ->keyBy('artwork_id'); + + $rows = []; + foreach ($artworkIds as $artworkId) { + $stat = $stats->get($artworkId); + + $rows[] = [ + 'artwork_id' => $artworkId, + 'bucket_hour' => $bucketHour, + 'views_count' => (int) ($stat?->views ?? 0), + 'downloads_count' => (int) ($stat?->downloads ?? 0), + 'favourites_count' => (int) ($stat?->favorites ?? 0), + 'comments_count' => (int) ($stat?->comments_count ?? 0), + 'shares_count' => (int) ($stat?->shares_count ?? 0), + 'created_at' => now(), + ]; + } + + if ($dryRun) { + $snapshotCount += count($rows); + continue; + } + + if (!empty($rows)) { + // Upsert: if (artwork_id, bucket_hour) already exists, update totals + DB::table('artwork_metric_snapshots_hourly')->upsert( + $rows, + ['artwork_id', 'bucket_hour'], + ['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count'] + ); + + $snapshotCount += count($rows); + } + } + + $this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}"); + + Log::info('[nova:metrics-snapshot-hourly] completed', [ + 'bucket' => $bucketHour->toDateTimeString(), + 'written' => $snapshotCount, + 'skipped' => $skipCount, + 'dry_run' => $dryRun, + ]); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PruneMetricSnapshotsCommand.php b/app/Console/Commands/PruneMetricSnapshotsCommand.php new file mode 100644 index 00000000..fedb4036 --- /dev/null +++ b/app/Console/Commands/PruneMetricSnapshotsCommand.php @@ -0,0 +1,40 @@ +option('keep-days'); + $cutoff = now()->subDays($keepDays); + + $deleted = DB::table('artwork_metric_snapshots_hourly') + ->where('bucket_hour', '<', $cutoff) + ->delete(); + + $this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days."); + + Log::info('[nova:prune-metric-snapshots] completed', [ + 'deleted' => $deleted, + 'keep_days' => $keepDays, + ]); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalculateHeatCommand.php b/app/Console/Commands/RecalculateHeatCommand.php new file mode 100644 index 00000000..5e34342f --- /dev/null +++ b/app/Console/Commands/RecalculateHeatCommand.php @@ -0,0 +1,166 @@ + 1, + 'downloads' => 3, + 'favourites' => 6, + 'comments' => 8, + 'shares' => 12, + ]; + + public function handle(): int + { + $days = (int) $this->option('days'); + $chunk = (int) $this->option('chunk'); + $dryRun = (bool) $this->option('dry-run'); + $now = now(); + $currentHour = $now->copy()->startOfHour(); + $prevHour = $currentHour->copy()->subHour(); + + $this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : '')); + + $updatedCount = 0; + $skippedCount = 0; + + // Process in chunks using artwork IDs that have at least one snapshot in the two hours + $artworkIds = DB::table('artwork_metric_snapshots_hourly') + ->whereIn('bucket_hour', [$currentHour, $prevHour]) + ->distinct() + ->pluck('artwork_id'); + + if ($artworkIds->isEmpty()) { + $this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.'); + return self::SUCCESS; + } + + // Load all snapshots for the two hours in bulk + $snapshots = DB::table('artwork_metric_snapshots_hourly') + ->whereIn('bucket_hour', [$currentHour, $prevHour]) + ->whereIn('artwork_id', $artworkIds) + ->get() + ->groupBy('artwork_id'); + + // Load artwork published_at dates for age factor (use published_at, fall back to created_at) + $artworkDates = DB::table('artworks') + ->whereIn('id', $artworkIds) + ->whereNull('deleted_at') + ->where('is_approved', true) + ->select('id', 'published_at', 'created_at') + ->get() + ->mapWithKeys(fn ($row) => [ + $row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at), + ]); + + // Process in chunks + foreach ($artworkIds->chunk($chunk) as $chunkIds) { + $upsertRows = []; + + foreach ($chunkIds as $artworkId) { + $createdAt = $artworkDates->get($artworkId); + if (!$createdAt) { + $skippedCount++; + continue; + } + + $artworkSnapshots = $snapshots->get($artworkId); + if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) { + $skippedCount++; + continue; + } + + $currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString()); + $prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString()); + + // If we only have one snapshot, use it as current with zero deltas + if (!$currentSnapshot && !$prevSnapshot) { + $skippedCount++; + continue; + } + + // Calculate deltas + $viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0)); + $downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0)); + $favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0)); + $commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0)); + $sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0)); + + // Raw heat + $rawHeat = ($viewsDelta * self::WEIGHTS['views']) + + ($downloadsDelta * self::WEIGHTS['downloads']) + + ($favouritesDelta * self::WEIGHTS['favourites']) + + ($commentsDelta * self::WEIGHTS['comments']) + + ($sharesDelta * self::WEIGHTS['shares']); + + // Age factor: favors newer works + $hoursSinceUpload = abs($now->floatDiffInHours($createdAt)); + $ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0)); + + // Final heat score + $heatScore = max(0, $rawHeat * $ageFactor); + + $upsertRows[] = [ + 'artwork_id' => $artworkId, + 'heat_score' => round($heatScore, 4), + 'heat_score_updated_at' => $now, + 'views_1h' => $viewsDelta, + 'downloads_1h' => $downloadsDelta, + 'favourites_1h' => $favouritesDelta, + 'comments_1h' => $commentsDelta, + 'shares_1h' => $sharesDelta, + ]; + + $updatedCount++; + } + + if (!$dryRun && !empty($upsertRows)) { + DB::table('artwork_stats')->upsert( + $upsertRows, + ['artwork_id'], + ['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h'] + ); + } + } + + $this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}"); + + Log::info('[nova:recalculate-heat] completed', [ + 'updated' => $updatedCount, + 'skipped' => $skippedCount, + 'dry_run' => $dryRun, + ]); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index ef97a632..a2f8e68d 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -14,6 +14,8 @@ use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\RecalculateTrendingCommand; use App\Console\Commands\RecalculateRankingsCommand; +use App\Console\Commands\MetricsSnapshotHourlyCommand; +use App\Console\Commands\RecalculateHeatCommand; use App\Jobs\RankComputeArtworkScoresJob; use App\Jobs\RankBuildListsJob; use App\Uploads\Commands\CleanupUploadsCommand; @@ -42,6 +44,8 @@ class Kernel extends ConsoleKernel \App\Console\Commands\MigrateFollows::class, RecalculateTrendingCommand::class, RecalculateRankingsCommand::class, + MetricsSnapshotHourlyCommand::class, + RecalculateHeatCommand::class, ]; /** @@ -68,6 +72,23 @@ class Kernel extends ConsoleKernel ->name('ranking-v2') ->withoutOverlapping() ->runInBackground(); + + // ── Rising Engine (Heat / Momentum) ───────────────────────────────── + // Step 1: snapshot metric totals every hour at :00 + $schedule->command('nova:metrics-snapshot-hourly') + ->hourly() + ->name('metrics-snapshot-hourly') + ->withoutOverlapping() + ->runInBackground(); + // Step 2: recalculate heat scores every 15 minutes + $schedule->command('nova:recalculate-heat') + ->everyFifteenMinutes() + ->name('recalculate-heat') + ->withoutOverlapping() + ->runInBackground(); + // Step 3: prune old snapshots daily at 04:00 + $schedule->command('nova:prune-metric-snapshots --keep-days=7') + ->dailyAt('04:00'); } /** diff --git a/app/Http/Controllers/Api/SimilarArtworksController.php b/app/Http/Controllers/Api/SimilarArtworksController.php index a76d765c..fd3df539 100644 --- a/app/Http/Controllers/Api/SimilarArtworksController.php +++ b/app/Http/Controllers/Api/SimilarArtworksController.php @@ -7,29 +7,33 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Services\ArtworkSearchService; +use App\Services\Recommendations\HybridSimilarArtworksService; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Cache; +use Illuminate\Http\Request; /** * GET /api/art/{id}/similar * - * Returns up to 12 similar artworks based on: - * 1. Tag overlap (primary signal) - * 2. Same category - * 3. Similar orientation + * Returns up to 12 similar artworks using the hybrid recommender (precomputed lists) + * with a Meilisearch-based fallback if no precomputed data exists. * - * Uses Meilisearch via ArtworkSearchService for fast retrieval. - * Current artwork and its creator are excluded from results. + * Query params: + * ?type=similar (default) | visual | tags | behavior + * + * Priority (default): + * 1. Hybrid precomputed (tag + behavior + optional vector) + * 2. Meilisearch tag-overlap fallback (legacy) */ final class SimilarArtworksController extends Controller { - private const LIMIT = 12; - /** Spec §5: cache similar artworks 30–60 min; using config with 30 min default. */ - private const CACHE_TTL = 1800; // 30 minutes + private const LIMIT = 12; - public function __construct(private readonly ArtworkSearchService $search) {} + public function __construct( + private readonly ArtworkSearchService $search, + private readonly HybridSimilarArtworksService $hybridService, + ) {} - public function __invoke(int $id): JsonResponse + public function __invoke(Request $request, int $id): JsonResponse { $artwork = Artwork::public() ->published() @@ -40,22 +44,64 @@ final class SimilarArtworksController extends Controller return response()->json(['error' => 'Artwork not found'], 404); } - $cacheKey = "api.similar.{$artwork->id}"; + $type = $request->query('type'); + $validTypes = ['similar', 'visual', 'tags', 'behavior']; + if ($type !== null && ! in_array($type, $validTypes, true)) { + $type = null; // ignore invalid, fall through to default + } - $items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) { - return $this->findSimilar($artwork); - }); + // Service handles its own caching (6h TTL), no extra controller-level cache + $hybridResults = $this->hybridService->forArtwork($artwork->id, self::LIMIT, $type); + + if ($hybridResults->isNotEmpty()) { + // Eager-load relations needed for formatting + $ids = $hybridResults->pluck('id')->all(); + $loaded = Artwork::query() + ->whereIn('id', $ids) + ->with(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']) + ->get() + ->keyBy('id'); + + $items = $hybridResults->values()->map(function (Artwork $a) use ($loaded) { + $full = $loaded->get($a->id) ?? $a; + return $this->formatArtwork($full); + })->all(); + + return response()->json(['data' => $items]); + } + + // Fall back to Meilisearch tag-overlap search + $items = $this->findSimilarViaSearch($artwork); return response()->json(['data' => $items]); } - private function findSimilar(Artwork $artwork): array + private function formatArtwork(Artwork $artwork): array + { + return [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'thumb' => $artwork->thumbUrl('md'), + 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, + 'author' => $artwork->user?->name ?? 'Artist', + 'author_avatar' => $artwork->user?->profile?->avatar_url, + 'author_id' => $artwork->user_id, + 'orientation' => $this->orientation($artwork), + 'width' => $artwork->width, + 'height' => $artwork->height, + ]; + } + + /** + * Legacy Meilisearch-based similar artworks (fallback). + */ + private function findSimilarViaSearch(Artwork $artwork): array { $tagSlugs = $artwork->tags->pluck('slug')->values()->all(); $categorySlugs = $artwork->categories->pluck('slug')->values()->all(); $srcOrientation = $this->orientation($artwork); - // Build Meilisearch filter: exclude self and same creator $filterParts = [ 'is_public = true', 'is_approved = true', @@ -63,7 +109,6 @@ final class SimilarArtworksController extends Controller 'author_id != ' . $artwork->user_id, ]; - // Priority 1: tag overlap (OR match across tags) if ($tagSlugs !== []) { $tagFilter = implode(' OR ', array_map( fn (string $t): string => 'tags = "' . addslashes($t) . '"', @@ -71,7 +116,6 @@ final class SimilarArtworksController extends Controller )); $filterParts[] = '(' . $tagFilter . ')'; } elseif ($categorySlugs !== []) { - // Fallback to category if no tags $catFilter = implode(' OR ', array_map( fn (string $c): string => 'category = "' . addslashes($c) . '"', $categorySlugs @@ -79,7 +123,6 @@ final class SimilarArtworksController extends Controller $filterParts[] = '(' . $catFilter . ')'; } - // ── Fetch 200-candidate pool from Meilisearch ───────────────────────── $results = Artwork::search('') ->options([ 'filter' => implode(' AND ', $filterParts), @@ -90,9 +133,6 @@ final class SimilarArtworksController extends Controller $collection = $results->getCollection(); $collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']); - // ── PHP reranking ────────────────────────────────────────────────────── - // Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus - // +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10 $srcTagSet = array_flip($tagSlugs); $srcW = (int) ($artwork->width ?? 0); $srcH = (int) ($artwork->height ?? 0); @@ -103,15 +143,12 @@ final class SimilarArtworksController extends Controller $cTagSlugs = $candidate->tags->pluck('slug')->all(); $cTagSet = array_flip($cTagSlugs); - // Tag overlap (Sørensen–Dice-like) $common = count(array_intersect_key($srcTagSet, $cTagSet)); $total = max(1, count($srcTagSet) + count($cTagSet) - $common); $tagOverlap = $common / $total; - // Orientation bonus $orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0; - // Resolution proximity bonus (both axes within 25 %) $cW = (int) ($candidate->width ?? 0); $cH = (int) ($candidate->height ?? 0); $resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0 @@ -119,11 +156,9 @@ final class SimilarArtworksController extends Controller && abs($cH - $srcH) / $srcH <= 0.25 ) ? 0.05 : 0.0; - // Popularity boost (log-normalised views, capped at 0.15) $views = max(0, (int) ($candidate->stats?->views ?? 0)); $popularity = min(0.15, log(1 + $views) / 13.0); - // Freshness boost (exp decay, 60-day half-life, weight 0.10) $publishedAt = $candidate->published_at ?? $candidate->created_at ?? now(); $ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400); $freshness = exp(-$ageDays / 60.0) * 0.10; @@ -140,20 +175,10 @@ final class SimilarArtworksController extends Controller usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']); return array_values( - array_map(fn (array $item): array => [ - 'id' => $item['artwork']->id, - 'title' => $item['artwork']->title, - 'slug' => $item['artwork']->slug, - 'thumb' => $item['artwork']->thumbUrl('md'), - 'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug, - 'author' => $item['artwork']->user?->name ?? 'Artist', - 'author_avatar' => $item['artwork']->user?->profile?->avatar_url, - 'author_id' => $item['artwork']->user_id, - 'orientation' => $this->orientation($item['artwork']), - 'width' => $item['artwork']->width, - 'height' => $item['artwork']->height, - 'score' => round((float) $item['score'], 5), - ], array_slice($scored, 0, self::LIMIT)) + array_map(fn (array $item): array => array_merge( + $this->formatArtwork($item['artwork']), + ['score' => round((float) $item['score'], 5)] + ), array_slice($scored, 0, self::LIMIT)) ); } diff --git a/app/Http/Controllers/ArtworkController.php b/app/Http/Controllers/ArtworkController.php index b301205a..76b79521 100644 --- a/app/Http/Controllers/ArtworkController.php +++ b/app/Http/Controllers/ArtworkController.php @@ -6,7 +6,6 @@ use App\Http\Controllers\Controller; use App\Http\Requests\ArtworkIndexRequest; use App\Models\Artwork; use App\Models\Category; -use App\Services\Recommendations\SimilarArtworksService; use Illuminate\Http\Request; use Illuminate\View\View; @@ -97,84 +96,12 @@ class ArtworkController extends Controller abort(404); } - $foundArtwork->loadMissing(['categories.contentType', 'user']); - - $defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1'); - $selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion); - - $similarService = app(SimilarArtworksService::class); - $similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion); - - if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) { - $similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion); - $selectedAlgoVersion = $defaultAlgoVersion; - } - - $similarArtworks->each(static function (Artwork $item): void { - $item->loadMissing(['categories.contentType', 'user']); - }); - - $similarItems = $similarArtworks - ->map(function (Artwork $item): ?array { - $category = $item->categories->first(); - $contentType = $category?->contentType; - - if (! $category || ! $contentType || empty($item->slug)) { - return null; - } - - return [ - 'id' => (int) $item->id, - 'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), - 'author' => html_entity_decode((string) optional($item->user)->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'), - 'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'), - 'thumb_srcset' => (string) ($item->thumb_srcset ?? ''), - 'url' => route('artworks.show', [ - 'contentTypeSlug' => (string) $contentType->slug, - 'categoryPath' => (string) $category->slug, - 'artwork' => (string) $item->slug, - ]), - ]; - }) - ->filter() - ->values(); - - return view('artworks.show', [ - 'artwork' => $foundArtwork, - 'similarItems' => $similarItems, - 'similarAlgoVersion' => $selectedAlgoVersion, - ]); - } - - private function selectAlgoVersionForRequest(Request $request, string $default): string - { - $configured = (array) config('recommendations.ab.algo_versions', []); - $versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured))); - - if ($versions === []) { - return $default; - } - - if (! in_array($default, $versions, true)) { - array_unshift($versions, $default); - $versions = array_values(array_unique($versions)); - } - - $forced = trim((string) $request->query('algo_version', '')); - if ($forced !== '' && in_array($forced, $versions, true)) { - return $forced; - } - - if (count($versions) === 1) { - return $versions[0]; - } - - $visitorKey = $request->user()?->id - ? 'u:' . (string) $request->user()->id - : 's:' . (string) $request->session()->getId(); - - $bucket = abs(crc32($visitorKey)) % count($versions); - - return $versions[$bucket] ?? $default; + // Delegate to the canonical ArtworkPageController which builds all + // required view data ($meta, thumbnails, related items, comments, etc.) + return app(\App\Http\Controllers\Web\ArtworkPageController::class)->show( + $request, + (int) $foundArtwork->id, + $foundArtwork->slug, + ); } } diff --git a/app/Http/Controllers/Legacy/LatestCommentsController.php b/app/Http/Controllers/Legacy/LatestCommentsController.php index 1c883ad1..7ad9f11e 100644 --- a/app/Http/Controllers/Legacy/LatestCommentsController.php +++ b/app/Http/Controllers/Legacy/LatestCommentsController.php @@ -30,7 +30,7 @@ class LatestCommentsController extends Controller $user = $c->user; $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; - $thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg'; + $thumb = $present ? ($present['url']) : 'https://files.skinbase.org/default/missing_md.webp'; return (object) [ 'comment_id' => $c->getKey(), diff --git a/app/Http/Controllers/Legacy/TodayDownloadsController.php b/app/Http/Controllers/Legacy/TodayDownloadsController.php index d8bf8c2b..a5da43b8 100644 --- a/app/Http/Controllers/Legacy/TodayDownloadsController.php +++ b/app/Http/Controllers/Legacy/TodayDownloadsController.php @@ -43,7 +43,7 @@ class TodayDownloadsController extends Controller $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $encoded = null; // legacy encoding unavailable; leave null $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; - $thumb = $present ? $present['url'] : '/gfx/sb_join.jpg'; + $thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp'; $categoryId = $art->categories->first()->id ?? null; return (object) [ diff --git a/app/Http/Controllers/LegacyController.php b/app/Http/Controllers/LegacyController.php index 68d4b1bc..b1b68a9c 100644 --- a/app/Http/Controllers/LegacyController.php +++ b/app/Http/Controllers/LegacyController.php @@ -58,7 +58,7 @@ class LegacyController extends Controller (object) [ 'id' => 0, 'name' => 'Sample Artwork', - 'picture' => 'gfx/sb_join.jpg', + 'picture' => 'https://files.skinbase.org/default/missing_md.webp', 'category' => null, 'datum' => now(), 'category_name' => 'Photography', @@ -289,7 +289,7 @@ class LegacyController extends Controller $featured = (object) [ 'id' => 0, 'name' => 'Featured Artwork', - 'picture' => '/gfx/sb_join.jpg', + 'picture' => 'https://files.skinbase.org/default/missing_md.webp', 'uname' => 'Skinbase', ]; } @@ -298,7 +298,7 @@ class LegacyController extends Controller $memberFeatured = (object) [ 'id' => 0, 'name' => 'Members Pick', - 'picture' => '/gfx/sb_join.jpg', + 'picture' => 'https://files.skinbase.org/default/missing_md.webp', 'uname' => 'Skinbase', 'votes' => 0, ]; @@ -430,7 +430,7 @@ class LegacyController extends Controller [ 'id' => 1, 'name' => 'Sample Artwork', - 'picture' => 'gfx/sb_join.jpg', + 'picture' => 'https://files.skinbase.org/default/missing_md.webp', 'uname' => 'Skinbase', 'category_name' => 'Photography', ], diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php new file mode 100644 index 00000000..4f6db964 --- /dev/null +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -0,0 +1,349 @@ +user()->id; + + $filters = $request->only([ + 'q', 'status', 'category', 'tags', 'date_from', 'date_to', + 'performance', 'sort', + ]); + + $perPage = (int) $request->get('per_page', 24); + $perPage = min(max($perPage, 12), 100); + + $paginator = $this->queryService->list($userId, $filters, $perPage); + + // Transform the paginator items to a clean DTO + $items = collect($paginator->items())->map(fn ($artwork) => $this->transformArtwork($artwork)); + + return response()->json([ + 'data' => $items, + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]); + } + + /** + * POST /api/studio/artworks/bulk + * Execute bulk operations. + */ + public function bulk(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags', + 'artwork_ids' => 'required|array|min:1|max:200', + 'artwork_ids.*' => 'integer', + 'params' => 'sometimes|array', + 'params.category_id' => 'sometimes|integer|exists:categories,id', + 'params.tag_ids' => 'sometimes|array', + 'params.tag_ids.*' => 'integer|exists:tags,id', + 'confirm' => 'required_if:action,delete|string', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $data = $validator->validated(); + + // Require explicit DELETE confirmation + if ($data['action'] === 'delete' && ($data['confirm'] ?? '') !== 'DELETE') { + return response()->json([ + 'errors' => ['confirm' => ['You must type DELETE to confirm permanent deletion.']], + ], 422); + } + + $result = $this->bulkService->execute( + $request->user()->id, + $data['action'], + $data['artwork_ids'], + $data['params'] ?? [], + ); + + $statusCode = $result['failed'] > 0 && $result['success'] === 0 ? 422 : 200; + + return response()->json($result, $statusCode); + } + + /** + * PUT /api/studio/artworks/{id} + * Update artwork details (title, description, visibility). + */ + public function update(Request $request, int $id): JsonResponse + { + $artwork = $request->user()->artworks()->findOrFail($id); + + $validated = $request->validate([ + 'title' => 'sometimes|string|max:255', + 'description' => 'sometimes|nullable|string|max:5000', + 'is_public' => 'sometimes|boolean', + 'category_id' => 'sometimes|nullable|integer|exists:categories,id', + 'tags' => 'sometimes|array|max:15', + 'tags.*' => 'string|max:64', + ]); + + if (isset($validated['is_public'])) { + if ($validated['is_public'] && !$artwork->is_public) { + $validated['published_at'] = $artwork->published_at ?? now(); + } + } + + // Extract tags and category before updating core fields + $tags = $validated['tags'] ?? null; + $categoryId = $validated['category_id'] ?? null; + unset($validated['tags'], $validated['category_id']); + + $artwork->update($validated); + + // Sync category + if ($categoryId !== null) { + $artwork->categories()->sync([(int) $categoryId]); + } + + // Sync tags (by slug/name) + if ($tags !== null) { + $tagIds = []; + foreach ($tags as $tagSlug) { + $tag = \App\Models\Tag::firstOrCreate( + ['slug' => \Illuminate\Support\Str::slug($tagSlug)], + ['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0] + ); + $tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0]; + } + $artwork->tags()->sync($tagIds); + } + + // Reindex in Meilisearch + try { + $artwork->searchable(); + } catch (\Throwable $e) { + // Meilisearch may be unavailable + } + + // Reload relationships for response + $artwork->load(['categories.contentType', 'tags']); + $primaryCategory = $artwork->categories->first(); + + return response()->json([ + 'success' => true, + 'artwork' => [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'description' => $artwork->description, + 'is_public' => (bool) $artwork->is_public, + 'slug' => $artwork->slug, + 'content_type_id' => $primaryCategory?->contentType?->id, + 'category_id' => $primaryCategory?->id, + 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), + ], + ]); + } + + /** + * POST /api/studio/artworks/{id}/toggle + * Toggle publish/unpublish/archive for a single artwork. + */ + public function toggle(Request $request, int $id): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'action' => 'required|string|in:publish,unpublish,archive,unarchive', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $result = $this->bulkService->execute( + $request->user()->id, + $validator->validated()['action'], + [$id], + ); + + if ($result['success'] === 0) { + return response()->json(['error' => 'Action failed', 'details' => $result['errors']], 404); + } + + return response()->json(['success' => true]); + } + + /** + * GET /api/studio/artworks/{id}/analytics + * Analytics data for a single artwork. + */ + public function analytics(Request $request, int $id): JsonResponse + { + $artwork = $request->user()->artworks() + ->with(['stats', 'awardStat']) + ->findOrFail($id); + + $stats = $artwork->stats; + + return response()->json([ + 'artwork' => [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + ], + 'analytics' => [ + 'views' => (int) ($stats?->views ?? 0), + 'favourites' => (int) ($stats?->favorites ?? 0), + 'shares' => (int) ($stats?->shares_count ?? 0), + 'comments' => (int) ($stats?->comments_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'ranking_score' => (float) ($stats?->ranking_score ?? 0), + 'heat_score' => (float) ($stats?->heat_score ?? 0), + 'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0), + ], + ]); + } + + private function transformArtwork($artwork): array + { + $stats = $artwork->stats ?? null; + + return [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg', + 'is_public' => (bool) $artwork->is_public, + 'is_approved' => (bool) $artwork->is_approved, + 'published_at' => $artwork->published_at?->toIso8601String(), + 'created_at' => $artwork->created_at?->toIso8601String(), + 'deleted_at' => $artwork->deleted_at?->toIso8601String(), + 'category' => $artwork->categories->first()?->name, + 'category_slug' => $artwork->categories->first()?->slug, + 'tags' => $artwork->tags->pluck('slug')->values()->all(), + 'views' => (int) ($stats?->views ?? 0), + 'favourites' => (int) ($stats?->favorites ?? 0), + 'shares' => (int) ($stats?->shares_count ?? 0), + 'comments' => (int) ($stats?->comments_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'ranking_score' => (float) ($stats?->ranking_score ?? 0), + 'heat_score' => (float) ($stats?->heat_score ?? 0), + ]; + } + + /** + * GET /api/studio/tags/search?q=... + * Search active tags by name for the bulk tag picker. + */ + public function searchTags(Request $request): JsonResponse + { + $query = trim((string) $request->input('q')); + + $tags = \App\Models\Tag::query() + ->where('is_active', true) + ->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%")) + ->orderByDesc('usage_count') + ->limit(30) + ->get(['id', 'name', 'slug', 'usage_count']); + + return response()->json($tags); + } + + /** + * POST /api/studio/artworks/{id}/replace-file + * Replace the artwork's primary image file and regenerate derivatives. + */ + public function replaceFile(Request $request, int $id): JsonResponse + { + $artwork = $request->user()->artworks()->findOrFail($id); + + $request->validate([ + 'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50MB + ]); + + $file = $request->file('file'); + $tempPath = $file->getRealPath(); + + // Compute SHA-256 hash + $hash = hash_file('sha256', $tempPath); + + try { + $derivatives = app(\App\Services\Uploads\UploadDerivativesService::class); + $storage = app(\App\Services\Uploads\UploadStorageService::class); + $artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class); + + // Store original + $originalPath = $derivatives->storeOriginal($tempPath, $hash); + $originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp'); + $artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath)); + + // Generate public derivatives + $publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash); + foreach ($publicAbsolute as $variant => $absolutePath) { + $filename = $variant . '.webp'; + $relativePath = $storage->publicRelativePath($hash, $filename); + $artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); + } + + // Get dimensions + $dimensions = @getimagesize($tempPath); + $width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width; + $height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height; + + // Update artwork record + $artwork->update([ + 'file_name' => 'orig.webp', + 'file_path' => '', + 'file_size' => (int) filesize($originalPath), + 'mime_type' => 'image/webp', + 'hash' => $hash, + 'file_ext' => 'webp', + 'thumb_ext' => 'webp', + 'width' => max(1, $width), + 'height' => max(1, $height), + ]); + + // Reindex + try { + $artwork->searchable(); + } catch (\Throwable) {} + + return response()->json([ + 'success' => true, + 'thumb_url' => $artwork->thumbUrl('md'), + 'thumb_url_lg' => $artwork->thumbUrl('lg'), + 'width' => $artwork->width, + 'height' => $artwork->height, + 'file_size' => $artwork->file_size, + ]); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'error' => 'File processing failed: ' . $e->getMessage(), + ], 500); + } + } +} diff --git a/app/Http/Controllers/Studio/StudioController.php b/app/Http/Controllers/Studio/StudioController.php new file mode 100644 index 00000000..fa5ebc74 --- /dev/null +++ b/app/Http/Controllers/Studio/StudioController.php @@ -0,0 +1,174 @@ +user()->id; + + return Inertia::render('Studio/StudioDashboard', [ + 'kpis' => $this->metrics->getDashboardKpis($userId), + 'topPerformers' => $this->metrics->getTopPerformers($userId, 6), + 'recentComments' => $this->metrics->getRecentComments($userId, 5), + ]); + } + + /** + * Artwork Manager (/studio/artworks) + */ + public function artworks(Request $request): Response + { + return Inertia::render('Studio/StudioArtworks', [ + 'categories' => $this->getCategories(), + ]); + } + + /** + * Drafts (/studio/artworks/drafts) + */ + public function drafts(Request $request): Response + { + return Inertia::render('Studio/StudioDrafts', [ + 'categories' => $this->getCategories(), + ]); + } + + /** + * Archived (/studio/artworks/archived) + */ + public function archived(Request $request): Response + { + return Inertia::render('Studio/StudioArchived', [ + 'categories' => $this->getCategories(), + ]); + } + + /** + * Edit artwork (/studio/artworks/:id/edit) + */ + public function edit(Request $request, int $id): Response + { + $artwork = $request->user()->artworks() + ->with(['stats', 'categories.contentType', 'tags']) + ->findOrFail($id); + + $primaryCategory = $artwork->categories->first(); + + return Inertia::render('Studio/StudioArtworkEdit', [ + 'artwork' => [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'description' => $artwork->description, + 'is_public' => (bool) $artwork->is_public, + 'is_approved' => (bool) $artwork->is_approved, + 'thumb_url' => $artwork->thumbUrl('md'), + 'thumb_url_lg' => $artwork->thumbUrl('lg'), + 'file_name' => $artwork->file_name, + 'file_size' => $artwork->file_size, + 'width' => $artwork->width, + 'height' => $artwork->height, + 'mime_type' => $artwork->mime_type, + 'content_type_id' => $primaryCategory?->contentType?->id, + 'category_id' => $primaryCategory?->id, + 'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id, + 'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null, + 'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(), + 'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(), + ], + 'contentTypes' => $this->getCategories(), + ]); + } + + /** + * Analytics v1 (/studio/artworks/:id/analytics) + */ + public function analytics(Request $request, int $id): Response + { + $artwork = $request->user()->artworks() + ->with(['stats', 'awardStat']) + ->findOrFail($id); + + $stats = $artwork->stats; + + return Inertia::render('Studio/StudioArtworkAnalytics', [ + 'artwork' => [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'thumb_url' => $artwork->thumbUrl('md'), + ], + 'analytics' => [ + 'views' => (int) ($stats?->views ?? 0), + 'favourites' => (int) ($stats?->favorites ?? 0), + 'shares' => (int) ($stats?->shares_count ?? 0), + 'comments' => (int) ($stats?->comments_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'ranking_score' => (float) ($stats?->ranking_score ?? 0), + 'heat_score' => (float) ($stats?->heat_score ?? 0), + 'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0), + ], + ]); + } + + /** + * Studio-wide Analytics (/studio/analytics) + */ + public function analyticsOverview(Request $request): Response + { + $userId = $request->user()->id; + $data = $this->metrics->getAnalyticsOverview($userId); + + return Inertia::render('Studio/StudioAnalytics', [ + 'totals' => $data['totals'], + 'topArtworks' => $data['top_artworks'], + 'contentBreakdown' => $data['content_breakdown'], + 'recentComments' => $this->metrics->getRecentComments($userId, 8), + ]); + } + + private function getCategories(): array + { + return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) { + return [ + 'id' => $ct->id, + 'name' => $ct->name, + 'slug' => $ct->slug, + 'categories' => $ct->rootCategories->map(function ($c) { + return [ + 'id' => $c->id, + 'name' => $c->name, + 'slug' => $c->slug, + 'children' => $c->children->map(fn ($ch) => [ + 'id' => $ch->id, + 'name' => $ch->name, + 'slug' => $ch->slug, + ])->values()->all(), + ]; + })->values()->all(), + ]; + })->values()->all(); + } +} diff --git a/app/Http/Controllers/User/TodayDownloadsController.php b/app/Http/Controllers/User/TodayDownloadsController.php index 95989d2c..25f34f17 100644 --- a/app/Http/Controllers/User/TodayDownloadsController.php +++ b/app/Http/Controllers/User/TodayDownloadsController.php @@ -39,7 +39,7 @@ class TodayDownloadsController extends Controller $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $encoded = null; $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; - $thumb = $present ? $present['url'] : '/gfx/sb_join.jpg'; + $thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp'; $categoryId = $art->categories->first()->id ?? null; return (object) [ diff --git a/app/Http/Controllers/User/TodayInHistoryController.php b/app/Http/Controllers/User/TodayInHistoryController.php index 04eeb4e5..74b8408a 100644 --- a/app/Http/Controllers/User/TodayInHistoryController.php +++ b/app/Http/Controllers/User/TodayInHistoryController.php @@ -68,11 +68,11 @@ class TodayInHistoryController extends Controller /** @var ?Artwork $art */ $art = $modelsById->get($row->id); if ($art) { - $row->thumb_url = $art->thumbUrl('md') ?? '/gfx/sb_join.jpg'; + $row->thumb_url = $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp'; $row->art_url = '/art/' . $art->id . '/' . $art->slug; $row->name = $art->title ?: ($row->name ?? 'Untitled'); } else { - $row->thumb_url = '/gfx/sb_join.jpg'; + $row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp'; $row->art_url = '/art/' . $row->id; $row->name = $row->name ?? 'Untitled'; } diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index 2d55365b..12755507 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -49,6 +49,23 @@ final class DiscoverController extends Controller ]); } + // ─── /discover/rising ──────────────────────────────────────────────────── + + public function rising(Request $request) + { + $perPage = 24; + $results = $this->searchService->discoverRising($perPage); + $this->hydrateDiscoverSearchResults($results); + + return view('web.discover.index', [ + 'artworks' => $results, + 'page_title' => 'Rising Now', + 'section' => 'rising', + 'description' => 'Fastest growing artworks right now.', + 'icon' => 'fa-rocket', + ]); + } + // ─── /discover/fresh ───────────────────────────────────────────────────── public function fresh(Request $request) diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 179df07b..2f273470 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -11,6 +11,18 @@ final class HandleInertiaRequests extends Middleware { protected $rootView = 'upload'; + /** + * Select the root Blade view based on route prefix. + */ + public function rootView(Request $request): string + { + if (str_starts_with($request->path(), 'studio')) { + return 'studio'; + } + + return $this->rootView; + } + public function version(Request $request): ?string { return parent::version($request); diff --git a/app/Jobs/RecBuildItemPairsFromFavouritesJob.php b/app/Jobs/RecBuildItemPairsFromFavouritesJob.php new file mode 100644 index 00000000..47a33617 --- /dev/null +++ b/app/Jobs/RecBuildItemPairsFromFavouritesJob.php @@ -0,0 +1,148 @@ +onQueue($queue); + } + } + + public function handle(): void + { + $favCap = (int) config('recommendations.similarity.user_favourites_cap', 50); + + // ── Pre-compute per-artwork total favourite counts for cosine normalization ── + $this->artworkLikeCounts = DB::table('artwork_favourites') + ->select('artwork_id', DB::raw('COUNT(*) as cnt')) + ->groupBy('artwork_id') + ->pluck('cnt', 'artwork_id') + ->all(); + + // ── Accumulate co-occurrence counts across all users ── + $coOccurrenceCounts = []; + + DB::table('artwork_favourites') + ->select('user_id') + ->groupBy('user_id') + ->orderBy('user_id') + ->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) { + foreach ($userRows as $row) { + $pairs = $this->pairsForUser((int) $row->user_id, $favCap); + foreach ($pairs as $pair) { + $key = $pair[0] . ':' . $pair[1]; + $coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1; + } + } + }); + + // ── Normalize to cosine-like scores and flush ── + $normalized = []; + foreach ($coOccurrenceCounts as $key => $count) { + [$a, $b] = explode(':', $key); + $likesA = $this->artworkLikeCounts[(int) $a] ?? 1; + $likesB = $this->artworkLikeCounts[(int) $b] ?? 1; + $normalized[$key] = $count / sqrt($likesA * $likesB); + } + + $this->flushPairs($normalized); + } + + /** @var array artwork_id => total favourite count */ + private array $artworkLikeCounts = []; + + /** + * Collect pairs from a single user's last N favourites. + * + * @return list + */ + public function pairsForUser(int $userId, int $cap): array + { + $artworkIds = DB::table('artwork_favourites') + ->where('user_id', $userId) + ->orderByDesc('created_at') + ->limit($cap) + ->pluck('artwork_id') + ->map(fn ($id) => (int) $id) + ->all(); + + $count = count($artworkIds); + if ($count < 2) { + return []; + } + + $pairs = []; + // Cap max pairs per user to avoid explosion: C(50,2) = 1225 worst case = acceptable + for ($i = 0; $i < $count - 1; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + $a = min($artworkIds[$i], $artworkIds[$j]); + $b = max($artworkIds[$i], $artworkIds[$j]); + $pairs[] = [$a, $b]; + } + } + + return $pairs; + } + + /** + * Upsert normalized pair weights into rec_item_pairs. + * + * Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite). + * + * @param array $upserts key = "a:b", value = cosine-normalized weight + */ + private function flushPairs(array $upserts): void + { + if ($upserts === []) { + return; + } + + $now = now(); + + foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) { + $rows = []; + foreach ($chunk as $key => $weight) { + [$a, $b] = explode(':', $key); + $rows[] = [ + 'a_artwork_id' => (int) $a, + 'b_artwork_id' => (int) $b, + 'weight' => $weight, + 'updated_at' => $now, + ]; + } + + DB::table('rec_item_pairs')->upsert( + $rows, + ['a_artwork_id', 'b_artwork_id'], + ['weight', 'updated_at'], + ); + } + } +} diff --git a/app/Jobs/RecComputeSimilarByBehaviorJob.php b/app/Jobs/RecComputeSimilarByBehaviorJob.php new file mode 100644 index 00000000..0bdd3e50 --- /dev/null +++ b/app/Jobs/RecComputeSimilarByBehaviorJob.php @@ -0,0 +1,129 @@ +onQueue($queue); + } + } + + public function handle(): void + { + $modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1'); + $resultLimit = (int) config('recommendations.similarity.result_limit', 30); + $maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2); + + $query = Artwork::query()->public()->published()->select('id', 'user_id'); + + if ($this->artworkId !== null) { + $query->where('id', $this->artworkId); + } + + $query->chunkById($this->batchSize, function ($artworks) use ($modelVersion, $resultLimit, $maxPerAuthor) { + foreach ($artworks as $artwork) { + $this->processArtwork($artwork, $modelVersion, $resultLimit, $maxPerAuthor); + } + }); + } + + private function processArtwork( + Artwork $artwork, + string $modelVersion, + int $resultLimit, + int $maxPerAuthor, + ): void { + // Fetch top co-occurring artworks (bi-directional) + $candidates = DB::table('rec_item_pairs') + ->where('a_artwork_id', $artwork->id) + ->select(DB::raw('b_artwork_id AS related_id'), 'weight') + ->union( + DB::table('rec_item_pairs') + ->where('b_artwork_id', $artwork->id) + ->select(DB::raw('a_artwork_id AS related_id'), 'weight') + ) + ->orderByDesc('weight') + ->limit($resultLimit * 3) + ->get(); + + if ($candidates->isEmpty()) { + return; + } + + $relatedIds = $candidates->pluck('related_id')->map(fn ($id) => (int) $id)->all(); + + // Fetch author info for diversity filtering + $authorMap = DB::table('artworks') + ->whereIn('id', $relatedIds) + ->where('is_public', true) + ->where('is_approved', true) + ->whereNotNull('published_at') + ->where('published_at', '<=', now()) + ->whereNull('deleted_at') + ->pluck('user_id', 'id') + ->all(); + + // Apply diversity cap + $authorCounts = []; + $final = []; + foreach ($candidates as $cand) { + $relatedId = (int) $cand->related_id; + if (! isset($authorMap[$relatedId])) { + continue; // not public/published + } + $authorId = (int) $authorMap[$relatedId]; + $authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1; + if ($authorCounts[$authorId] > $maxPerAuthor) { + continue; + } + $final[] = $relatedId; + if (count($final) >= $resultLimit) { + break; + } + } + + if ($final === []) { + return; + } + + RecArtworkRec::query()->updateOrCreate( + [ + 'artwork_id' => $artwork->id, + 'rec_type' => 'similar_behavior', + 'model_version' => $modelVersion, + ], + [ + 'recs' => $final, + 'computed_at' => now(), + ], + ); + } +} diff --git a/app/Jobs/RecComputeSimilarByTagsJob.php b/app/Jobs/RecComputeSimilarByTagsJob.php new file mode 100644 index 00000000..7c6e9012 --- /dev/null +++ b/app/Jobs/RecComputeSimilarByTagsJob.php @@ -0,0 +1,225 @@ +onQueue($queue); + } + } + + public function handle(): void + { + $modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1'); + $candidatePool = (int) config('recommendations.similarity.candidate_pool', 100); + $maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2); + $resultLimit = (int) config('recommendations.similarity.result_limit', 30); + + // ── Tag IDF weights (global) ─────────────────────────────────────────── + $tagFreqs = DB::table('artwork_tag') + ->select('tag_id', DB::raw('COUNT(*) as cnt')) + ->groupBy('tag_id') + ->pluck('cnt', 'tag_id') + ->all(); + + $query = Artwork::query()->public()->published()->select('id', 'user_id'); + + if ($this->artworkId !== null) { + $query->where('id', $this->artworkId); + } + + $query->chunkById($this->batchSize, function ($artworks) use ( + $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit + ) { + foreach ($artworks as $artwork) { + $this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit); + } + }); + } + + private function processArtwork( + Artwork $artwork, + array $tagFreqs, + string $modelVersion, + int $candidatePool, + int $maxPerAuthor, + int $resultLimit, + ): void { + // Get source artwork's tags and categories + $srcTagIds = DB::table('artwork_tag') + ->where('artwork_id', $artwork->id) + ->pluck('tag_id') + ->all(); + + $srcCatIds = DB::table('artwork_category') + ->where('artwork_id', $artwork->id) + ->pluck('category_id') + ->all(); + + // Source content_type_ids (via categories) + $srcContentTypeIds = $srcCatIds !== [] + ? DB::table('categories') + ->whereIn('id', $srcCatIds) + ->whereNotNull('content_type_id') + ->pluck('content_type_id') + ->unique() + ->all() + : []; + + if ($srcTagIds === [] && $srcCatIds === []) { + return; + } + + // ── Find candidates that share at least one tag ──────────────────────── + $candidateQuery = DB::table('artwork_tag') + ->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id') + ->whereIn('artwork_tag.tag_id', $srcTagIds) + ->where('artwork_tag.artwork_id', '!=', $artwork->id) + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNotNull('artworks.published_at') + ->where('artworks.published_at', '<=', now()) + ->whereNull('artworks.deleted_at') + ->select('artwork_tag.artwork_id', 'artworks.user_id') + ->groupBy('artwork_tag.artwork_id', 'artworks.user_id') + ->orderByRaw('COUNT(*) DESC') + ->limit($candidatePool * 3); // over-fetch before scoring + + $candidates = $candidateQuery->get(); + + if ($candidates->isEmpty()) { + return; + } + + // Gather tags for all candidates in one query + $candidateIds = $candidates->pluck('artwork_id')->all(); + $candidateTagMap = DB::table('artwork_tag') + ->whereIn('artwork_id', $candidateIds) + ->select('artwork_id', 'tag_id') + ->get() + ->groupBy('artwork_id'); + + $candidateCatMap = DB::table('artwork_category') + ->whereIn('artwork_id', $candidateIds) + ->select('artwork_id', 'category_id') + ->get() + ->groupBy('artwork_id'); + + // Build content_type_id lookup for candidates (via categories table) + $allCandidateCatIds = $candidateCatMap->flatten(1)->pluck('category_id')->unique()->all(); + $catContentTypeMap = $allCandidateCatIds !== [] + ? DB::table('categories') + ->whereIn('id', $allCandidateCatIds) + ->whereNotNull('content_type_id') + ->pluck('content_type_id', 'id') + ->all() + : []; + $srcContentTypeSet = array_flip($srcContentTypeIds); + + $srcTagSet = array_flip($srcTagIds); + $srcCatSet = array_flip($srcCatIds); + + // ── Score each candidate ─────────────────────────────────────────────── + $scored = []; + foreach ($candidates as $cand) { + $cTagIds = $candidateTagMap->get($cand->artwork_id, collect())->pluck('tag_id')->all(); + $cCatIds = $candidateCatMap->get($cand->artwork_id, collect())->pluck('category_id')->all(); + + // IDF-weighted tag overlap (spec §5.1) + $tagScore = 0.0; + foreach ($cTagIds as $tagId) { + if (isset($srcTagSet[$tagId])) { + $freq = $tagFreqs[$tagId] ?? 1; + $tagScore += 1.0 / log(2 + $freq); + } + } + + // Category match bonus + $catScore = 0.0; + foreach ($cCatIds as $catId) { + if (isset($srcCatSet[$catId])) { + $catScore = 1.0; + break; + } + } + + // Content type match bonus (spec §5.1) + $ctScore = 0.0; + foreach ($cCatIds as $catId) { + $ctId = $catContentTypeMap[$catId] ?? null; + if ($ctId !== null && isset($srcContentTypeSet[$ctId])) { + $ctScore = 1.0; + break; + } + } + + $scored[] = [ + 'artwork_id' => (int) $cand->artwork_id, + 'user_id' => (int) $cand->user_id, + 'tag_score' => $tagScore, + 'cat_score' => $catScore, + 'score' => $tagScore + $catScore * 0.1 + $ctScore * 0.05, + ]; + } + + // Sort by score descending + usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']); + + // ── Apply diversity (max per author) ─────────────────────────────────── + $authorCounts = []; + $final = []; + foreach ($scored as $item) { + $authorId = $item['user_id']; + $authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1; + if ($authorCounts[$authorId] > $maxPerAuthor) { + continue; + } + $final[] = $item['artwork_id']; + if (count($final) >= $resultLimit) { + break; + } + } + + // ── Persist ──────────────────────────────────────────────────────────── + RecArtworkRec::query()->updateOrCreate( + [ + 'artwork_id' => $artwork->id, + 'rec_type' => 'similar_tags', + 'model_version' => $modelVersion, + ], + [ + 'recs' => $final, + 'computed_at' => now(), + ], + ); + } +} diff --git a/app/Jobs/RecComputeSimilarHybridJob.php b/app/Jobs/RecComputeSimilarHybridJob.php new file mode 100644 index 00000000..9dd9e19d --- /dev/null +++ b/app/Jobs/RecComputeSimilarHybridJob.php @@ -0,0 +1,286 @@ +onQueue($queue); + } + } + + public function handle(): void + { + $modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1'); + $vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false); + $resultLimit = (int) config('recommendations.similarity.result_limit', 30); + $maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2); + $minCatsTop12 = (int) config('recommendations.similarity.min_categories_top12', 2); + + $weights = $vectorEnabled + ? (array) config('recommendations.similarity.weights_with_vector') + : (array) config('recommendations.similarity.weights_without_vector'); + + $query = Artwork::query()->public()->published()->select('id', 'user_id'); + + if ($this->artworkId !== null) { + $query->where('id', $this->artworkId); + } + + $query->chunkById($this->batchSize, function ($artworks) use ( + $modelVersion, $vectorEnabled, $resultLimit, $maxPerAuthor, $minCatsTop12, $weights + ) { + foreach ($artworks as $artwork) { + try { + $this->processArtwork( + $artwork, $modelVersion, $vectorEnabled, $resultLimit, + $maxPerAuthor, $minCatsTop12, $weights + ); + } catch (\Throwable $e) { + Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}"); + } + } + }); + } + + private function processArtwork( + Artwork $artwork, + string $modelVersion, + bool $vectorEnabled, + int $resultLimit, + int $maxPerAuthor, + int $minCatsTop12, + array $weights, + ): void { + // ── Collect sub-lists ────────────────────────────────────────────────── + $tagRec = RecArtworkRec::query() + ->where('artwork_id', $artwork->id) + ->where('rec_type', 'similar_tags') + ->where('model_version', $modelVersion) + ->first(); + + $behRec = RecArtworkRec::query() + ->where('artwork_id', $artwork->id) + ->where('rec_type', 'similar_behavior') + ->where('model_version', $modelVersion) + ->first(); + + $tagIds = $tagRec ? ($tagRec->recs ?? []) : []; + $behIds = $behRec ? ($behRec->recs ?? []) : []; + + $vecIds = []; + $vecScores = []; + if ($vectorEnabled) { + $vecRec = RecArtworkRec::query() + ->where('artwork_id', $artwork->id) + ->where('rec_type', 'similar_visual') + ->where('model_version', $modelVersion) + ->first(); + if ($vecRec) { + $vecIds = $vecRec->recs ?? []; + } + } + + // Merge all candidate IDs + $allIds = array_values(array_unique(array_merge($tagIds, $behIds, $vecIds))); + + if ($allIds === []) { + return; + } + + // ── Build normalized score maps ──────────────────────────────────────── + $tagScoreMap = $this->rankToScore($tagIds); + $behScoreMap = $this->rankToScore($behIds); + $vecScoreMap = $this->rankToScore($vecIds); + + // Fetch artwork metadata for category + author diversity + $metaRows = DB::table('artworks') + ->whereIn('id', $allIds) + ->where('is_public', true) + ->where('is_approved', true) + ->whereNotNull('published_at') + ->where('published_at', '<=', now()) + ->whereNull('deleted_at') + ->select('id', 'user_id') + ->get() + ->keyBy('id'); + + $catMap = DB::table('artwork_category') + ->whereIn('artwork_id', $allIds) + ->select('artwork_id', 'category_id') + ->get() + ->groupBy('artwork_id'); + + // Source artwork categories + $srcCatIds = DB::table('artwork_category') + ->where('artwork_id', $artwork->id) + ->pluck('category_id') + ->all(); + $srcCatSet = array_flip($srcCatIds); + + // ── Compute hybrid score ─────────────────────────────────────────────── + $scored = []; + foreach ($allIds as $candidateId) { + if (! $metaRows->has($candidateId)) { + continue; + } + + $meta = $metaRows->get($candidateId); + $candidateCats = $catMap->get($candidateId, collect())->pluck('category_id')->all(); + + // Category overlap + $catScore = 0.0; + foreach ($candidateCats as $catId) { + if (isset($srcCatSet[$catId])) { + $catScore = 1.0; + break; + } + } + + $tagS = $tagScoreMap[$candidateId] ?? 0.0; + $behS = $behScoreMap[$candidateId] ?? 0.0; + $vecS = $vecScoreMap[$candidateId] ?? 0.0; + + if ($vectorEnabled) { + $score = ($weights['visual'] ?? 0.45) * $vecS + + ($weights['tag'] ?? 0.25) * $tagS + + ($weights['behavior'] ?? 0.20) * $behS + + ($weights['category'] ?? 0.10) * $catScore; + } else { + $score = ($weights['tag'] ?? 0.55) * $tagS + + ($weights['behavior'] ?? 0.35) * $behS + + ($weights['category'] ?? 0.10) * $catScore; + } + + $scored[] = [ + 'artwork_id' => $candidateId, + 'user_id' => (int) $meta->user_id, + 'cat_ids' => $candidateCats, + 'score' => $score, + ]; + } + + usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']); + + // ── Diversity enforcement ────────────────────────────────────────────── + $authorCounts = []; + $final = []; + $catsInTop12 = []; + + foreach ($scored as $item) { + $authorId = $item['user_id']; + $authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1; + + if ($authorCounts[$authorId] > $maxPerAuthor) { + continue; + } + + $final[] = $item; + + if (count($final) <= 12) { + foreach ($item['cat_ids'] as $cId) { + $catsInTop12[$cId] = true; + } + } + + if (count($final) >= $resultLimit) { + break; + } + } + + // ── Min-categories enforcement in top 12 (spec §6) ──────────────────── + if (count($catsInTop12) < $minCatsTop12 && count($final) >= 12) { + // Find items beyond the initial selection that introduce a new category + $usedIds = array_flip(array_column($final, 'artwork_id')); + $promotable = []; + foreach ($scored as $item) { + if (isset($usedIds[$item['artwork_id']])) { + continue; + } + $newCats = array_diff($item['cat_ids'], array_keys($catsInTop12)); + if ($newCats !== []) { + $promotable[] = $item; + if (count($promotable) >= ($minCatsTop12 - count($catsInTop12))) { + break; + } + } + } + // Inject promoted items at position 12 (end of visible top block) + if ($promotable !== []) { + $top = array_slice($final, 0, 11); + $rest = array_slice($final, 11); + $final = array_merge($top, $promotable, $rest); + $final = array_slice($final, 0, $resultLimit); + } + } + + $finalIds = array_column($final, 'artwork_id'); + + if ($finalIds === []) { + return; + } + + RecArtworkRec::query()->updateOrCreate( + [ + 'artwork_id' => $artwork->id, + 'rec_type' => 'similar_hybrid', + 'model_version' => $modelVersion, + ], + [ + 'recs' => $finalIds, + 'computed_at' => now(), + ], + ); + } + + /** + * Convert a ranked list of IDs into a score map (1.0 at rank 0, decaying). + * + * @param list $ids + * @return array + */ + private function rankToScore(array $ids): array + { + $map = []; + $total = count($ids); + if ($total === 0) { + return $map; + } + + foreach ($ids as $rank => $id) { + // Linear decay from 1.0 → ~0.0 + $map[(int) $id] = 1.0 - ($rank / max(1, $total)); + } + + return $map; + } +} diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 13229bcc..20bf16fe 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -86,7 +86,7 @@ class Artwork extends Model */ public function getThumbAttribute(): string { - return $this->thumbUrl('md') ?? '/gfx/sb_join.jpg'; + return $this->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp'; } /** @@ -261,6 +261,8 @@ class Artwork extends Model 'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0), 'shares_count' => (int) ($stat?->shares_count ?? 0), 'comments_count' => (int) ($stat?->comments_count ?? 0), + // ── Rising / Heat fields ──────────────────────────────────────────────────── + 'heat_score' => (float) ($stat?->heat_score ?? 0), 'awards' => [ 'gold' => $awardStat?->gold_count ?? 0, 'silver' => $awardStat?->silver_count ?? 0, diff --git a/app/Models/ArtworkMetricSnapshotHourly.php b/app/Models/ArtworkMetricSnapshotHourly.php new file mode 100644 index 00000000..eb70a4fd --- /dev/null +++ b/app/Models/ArtworkMetricSnapshotHourly.php @@ -0,0 +1,53 @@ + 'datetime', + 'views_count' => 'integer', + 'downloads_count' => 'integer', + 'favourites_count' => 'integer', + 'comments_count' => 'integer', + 'shares_count' => 'integer', + ]; + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'artwork_id'); + } +} diff --git a/app/Models/ArtworkStats.php b/app/Models/ArtworkStats.php index a093a314..3fd289e7 100644 --- a/app/Models/ArtworkStats.php +++ b/app/Models/ArtworkStats.php @@ -34,6 +34,14 @@ class ArtworkStats extends Model 'shares_24h', 'comments_24h', 'favourites_24h', + // Rising / Heat columns + 'heat_score', + 'heat_score_updated_at', + 'views_1h', + 'favourites_1h', + 'comments_1h', + 'shares_1h', + 'downloads_1h', ]; public function artwork(): BelongsTo diff --git a/app/Models/RecArtworkRec.php b/app/Models/RecArtworkRec.php new file mode 100644 index 00000000..38d1de40 --- /dev/null +++ b/app/Models/RecArtworkRec.php @@ -0,0 +1,48 @@ + 'integer', + 'recs' => 'array', + 'computed_at' => 'datetime', + ]; + + // ── Relations ────────────────────────────────────────────────────────── + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'artwork_id'); + } +} diff --git a/app/Models/RecEvent.php b/app/Models/RecEvent.php new file mode 100644 index 00000000..69c49119 --- /dev/null +++ b/app/Models/RecEvent.php @@ -0,0 +1,54 @@ + 'integer', + 'artwork_id' => 'integer', + 'created_at' => 'datetime', + ]; + + // ── Relations ────────────────────────────────────────────────────────── + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } +} diff --git a/app/Models/RecItemPair.php b/app/Models/RecItemPair.php new file mode 100644 index 00000000..0872f57a --- /dev/null +++ b/app/Models/RecItemPair.php @@ -0,0 +1,54 @@ + 'integer', + 'b_artwork_id' => 'integer', + 'weight' => 'double', + 'updated_at' => 'datetime', + ]; + + // ── Relations ────────────────────────────────────────────────────────── + + public function artworkA(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'a_artwork_id'); + } + + public function artworkB(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'b_artwork_id'); + } +} diff --git a/app/Observers/ArtworkFavouriteObserver.php b/app/Observers/ArtworkFavouriteObserver.php index 1d862478..29dabff5 100644 --- a/app/Observers/ArtworkFavouriteObserver.php +++ b/app/Observers/ArtworkFavouriteObserver.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Observers; +use App\Jobs\RecComputeSimilarByBehaviorJob; +use App\Jobs\RecComputeSimilarHybridJob; use App\Models\ArtworkFavourite; use App\Services\UserStatsService; use Illuminate\Support\Facades\DB; @@ -24,6 +26,9 @@ class ArtworkFavouriteObserver if ($creatorId) { $this->userStats->incrementFavoritesReceived($creatorId); } + + // §7.5 On-demand: recompute behavior similarity when artwork reaches threshold + $this->maybeRecomputeBehavior($favourite->artwork_id); } public function deleted(ArtworkFavourite $favourite): void @@ -42,4 +47,22 @@ class ArtworkFavouriteObserver return $id !== null ? (int) $id : null; } + + /** + * Dispatch on-demand behavior recomputation when an artwork crosses a + * favourites threshold (5, 10, 25, 50 …). + */ + private function maybeRecomputeBehavior(int $artworkId): void + { + $count = (int) DB::table('artwork_favourites') + ->where('artwork_id', $artworkId) + ->count(); + + $thresholds = [5, 10, 25, 50, 100]; + + if (in_array($count, $thresholds, true)) { + RecComputeSimilarByBehaviorJob::dispatch($artworkId)->delay(now()->addSeconds(30)); + RecComputeSimilarHybridJob::dispatch($artworkId)->delay(now()->addMinute()); + } + } } diff --git a/app/Observers/ArtworkObserver.php b/app/Observers/ArtworkObserver.php index 8d75d41a..067086f2 100644 --- a/app/Observers/ArtworkObserver.php +++ b/app/Observers/ArtworkObserver.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Observers; use App\Models\Artwork; +use App\Jobs\RecComputeSimilarByTagsJob; +use App\Jobs\RecComputeSimilarHybridJob; use App\Services\ArtworkSearchIndexer; use App\Services\UserStatsService; @@ -39,6 +41,14 @@ class ArtworkObserver } $this->indexer->update($artwork); + + // §7.5 On-demand: recompute similarity when tags/categories could have changed. + // The pivot sync happens outside this observer, so we dispatch on every + // meaningful update and let the job be idempotent (cheap if nothing changed). + if ($artwork->is_public && $artwork->published_at) { + RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30)); + RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinutes(1)); + } } /** Soft delete — remove from search and decrement uploads_count. */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c9586825..e3c15758 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -36,6 +36,12 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(UploadDraftServiceInterface::class, function ($app) { return new UploadDraftService($app->make('filesystem')); }); + + // Bind vector adapter interface for similarity system (resolves via factory) + $this->app->bind( + \App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface::class, + fn () => \App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory::make(), + ); } /** diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php index 737355b5..bc224802 100644 --- a/app/Services/ArtworkSearchService.php +++ b/app/Services/ArtworkSearchService.php @@ -269,6 +269,27 @@ final class ArtworkSearchService }); } + /** + * Rising: sorted by heat_score (recalculated every 15 min). + * + * Surfaces artworks with rapid recent engagement growth. + * Restricts to last 30 days, sorted by heat_score DESC. + */ + public function discoverRising(int $perPage = 24): LengthAwarePaginator + { + $page = (int) request()->get('page', 1); + $cutoff = now()->subDays(30)->toDateString(); + + return Cache::remember("discover.rising.{$page}", 120, function () use ($perPage, $cutoff) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', + 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'], + ]) + ->paginate($perPage); + }); + } + /** * Fresh: newest uploads first. */ diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index e775bf31..db827bc5 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -44,6 +44,7 @@ final class HomepageService { return [ 'hero' => $this->getHeroArtwork(), + 'rising' => $this->getRising(), 'trending' => $this->getTrending(), 'fresh' => $this->getFreshUploads(), 'tags' => $this->getPopularTags(), @@ -74,6 +75,7 @@ final class HomepageService 'hero' => $this->getHeroArtwork(), 'for_you' => $this->getForYouPreview($user), 'from_following' => $this->getFollowingFeed($user, $prefs), + 'rising' => $this->getRising(), 'trending' => $this->getTrending(), 'fresh' => $this->getFreshUploads(), 'by_tags' => $this->getByTags($prefs['top_tags'] ?? []), @@ -132,6 +134,65 @@ final class HomepageService }); } + /** + * Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min). + * + * Surfaces artworks with the fastest recent engagement growth. + * Falls back to DB ORDER BY heat_score if Meilisearch is unavailable. + */ + public function getRising(int $limit = 10): array + { + $cutoff = now()->subDays(30)->toDateString(); + + return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array { + try { + $results = Artwork::search('') + ->options([ + 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', + 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'], + ]) + ->paginate($limit, 'page', 1); + + $results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']); + + if ($results->isEmpty()) { + return $this->getRisingFromDb($limit); + } + + return $results->getCollection() + ->map(fn ($a) => $this->serializeArtwork($a)) + ->values() + ->all(); + } catch (\Throwable $e) { + Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [ + 'error' => $e->getMessage(), + ]); + + return $this->getRisingFromDb($limit); + } + }); + } + + /** + * DB-only fallback for rising (Meilisearch unavailable). + */ + private function getRisingFromDb(int $limit): array + { + return Artwork::public() + ->published() + ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) + ->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->where('artworks.published_at', '>=', now()->subDays(30)) + ->orderByDesc('artwork_stats.heat_score') + ->orderByDesc('artwork_stats.engagement_velocity') + ->limit($limit) + ->get() + ->map(fn ($a) => $this->serializeArtwork($a)) + ->values() + ->all(); + } + /** * Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`. * diff --git a/app/Services/LegacyService.php b/app/Services/LegacyService.php index 7916864f..18a0e4a9 100644 --- a/app/Services/LegacyService.php +++ b/app/Services/LegacyService.php @@ -49,7 +49,7 @@ class LegacyService $featured = (object) [ 'id' => 0, 'name' => 'Featured Artwork', - 'picture' => '/gfx/sb_join.jpg', + 'picture' => 'https://files.skinbase.org/default/missing_md.webp', 'uname' => 'Skinbase', ]; } @@ -58,7 +58,7 @@ class LegacyService $memberFeatured = (object) [ 'id' => 0, 'name' => 'Members Pick', - 'picture' => '/gfx/sb_join.jpg', + 'picture' => 'https://files.skinbase.org/default/missing_md.webp', 'uname' => 'Skinbase', 'votes' => 0, ]; @@ -106,7 +106,7 @@ class LegacyService [ 'id' => 1, 'name' => 'Sample Artwork', - 'picture' => 'gfx/sb_join.jpg', + 'picture' => 'https://files.skinbase.org/default/missing_md.webp', 'uname' => 'Skinbase', 'category_name' => 'Photography', ], @@ -282,7 +282,7 @@ class LegacyService } else { $row->ext = null; $row->encoded = null; - $row->thumb_url = '/gfx/sb_join.jpg'; + $row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp'; $row->thumb_srcset = null; } diff --git a/app/Services/Recommendations/HybridSimilarArtworksService.php b/app/Services/Recommendations/HybridSimilarArtworksService.php new file mode 100644 index 00000000..d217be2a --- /dev/null +++ b/app/Services/Recommendations/HybridSimilarArtworksService.php @@ -0,0 +1,180 @@ + + */ + public function forArtwork(int $artworkId, int $limit = 12, ?string $type = null): Collection + { + $modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1'); + $cacheTtl = (int) config('recommendations.similarity.cache_ttl', 6 * 3600); + $maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2); + $vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false); + + $typeSuffix = $type && $type !== 'similar' ? ":{$type}" : ''; + $cacheKey = "rec:artwork:{$artworkId}:similar:{$modelVersion}{$typeSuffix}"; + + $ids = Cache::remember($cacheKey, $cacheTtl, function () use ( + $artworkId, $modelVersion, $vectorEnabled, $type + ): array { + return $this->resolveIds($artworkId, $modelVersion, $vectorEnabled, $type); + }); + + if ($ids === []) { + return collect(); + } + + // Take requested limit + buffer for author-diversity filtering + $idSlice = array_slice($ids, 0, $limit * 3); + + $artworks = Artwork::query() + ->whereIn('id', $idSlice) + ->public() + ->published() + ->get() + ->keyBy('id'); + + // Preserve precomputed order + apply author cap + $authorCounts = []; + $result = []; + + foreach ($idSlice as $id) { + /** @var Artwork|null $artwork */ + $artwork = $artworks->get($id); + if (! $artwork) { + continue; + } + + $authorId = $artwork->user_id; + $authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1; + if ($authorCounts[$authorId] > $maxPerAuthor) { + continue; + } + + $result[] = $artwork; + if (count($result) >= $limit) { + break; + } + } + + return collect($result); + } + + /** + * Resolve the precomputed ID list, falling through rec types. + * + * @return list + */ + private function resolveIds(int $artworkId, string $modelVersion, bool $vectorEnabled, ?string $type = null): array + { + // If a specific type was requested, try only that type + trending fallback + if ($type && $type !== 'similar') { + $recType = match ($type) { + 'visual' => 'similar_visual', + 'tags' => 'similar_tags', + 'behavior' => 'similar_behavior', + default => null, + }; + + if ($recType) { + $rec = RecArtworkRec::query() + ->where('artwork_id', $artworkId) + ->where('rec_type', $recType) + ->where('model_version', $modelVersion) + ->first(); + + if ($rec && is_array($rec->recs) && $rec->recs !== []) { + return array_map('intval', $rec->recs); + } + } + + return $this->trendingFallback($artworkId); + } + + // Default: hybrid fallback chain + $tryTypes = $vectorEnabled + ? self::FALLBACK_ORDER + : array_filter(self::FALLBACK_ORDER, fn (string $t) => $t !== 'similar_visual'); + + foreach ($tryTypes as $recType) { + $rec = RecArtworkRec::query() + ->where('artwork_id', $artworkId) + ->where('rec_type', $recType) + ->where('model_version', $modelVersion) + ->first(); + + if ($rec && is_array($rec->recs) && $rec->recs !== []) { + return array_map('intval', $rec->recs); + } + } + + // ── Trending fallback (category-scoped) ──────────────────────────────── + return $this->trendingFallback($artworkId); + } + + /** + * Trending fallback: fetch recent popular artworks in the same category. + * + * @return list + */ + private function trendingFallback(int $artworkId): array + { + $catIds = DB::table('artwork_category') + ->where('artwork_id', $artworkId) + ->pluck('category_id') + ->all(); + + $query = Artwork::query() + ->public() + ->published() + ->where('id', '!=', $artworkId); + + if ($catIds !== []) { + $query->whereHas('categories', function ($q) use ($catIds) { + $q->whereIn('categories.id', $catIds); + }); + } + + return $query + ->orderByDesc('published_at') + ->limit(30) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } +} diff --git a/app/Services/Recommendations/VectorSimilarity/PgvectorAdapter.php b/app/Services/Recommendations/VectorSimilarity/PgvectorAdapter.php new file mode 100644 index 00000000..2d40bea2 --- /dev/null +++ b/app/Services/Recommendations/VectorSimilarity/PgvectorAdapter.php @@ -0,0 +1,84 @@ +where('artwork_id', $artworkId) + ->select('embedding_json') + ->first(); + + if (! $ref || ! $ref->embedding_json) { + return []; + } + + $embedding = json_decode($ref->embedding_json, true); + if (! is_array($embedding) || $embedding === []) { + return []; + } + + // pgvector cosine distance operator: <=> + // Score = 1 - distance (higher = more similar) + $vecLiteral = '[' . implode(',', array_map('floatval', $embedding)) . ']'; + + try { + $rows = DB::select( + "SELECT artwork_id, 1 - (embedding_json::vector <=> ?::vector) AS score + FROM artwork_embeddings + WHERE artwork_id != ? + ORDER BY embedding_json::vector <=> ?::vector + LIMIT ?", + [$vecLiteral, $artworkId, $vecLiteral, $topK] + ); + } catch (\Throwable $e) { + Log::warning("[PgvectorAdapter] Query failed: {$e->getMessage()}"); + return []; + } + + return array_map(fn ($row) => [ + 'artwork_id' => (int) $row->artwork_id, + 'score' => (float) $row->score, + ], $rows); + } + + public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void + { + $json = json_encode($embedding); + + DB::table('artwork_embeddings')->updateOrInsert( + ['artwork_id' => $artworkId], + [ + 'embedding_json' => $json, + 'model' => $metadata['model'] ?? 'clip', + 'model_version' => $metadata['model_version'] ?? 'v1', + 'dim' => count($embedding), + 'is_normalized' => $metadata['is_normalized'] ?? true, + 'generated_at' => now(), + ], + ); + } + + public function deleteEmbedding(int $artworkId): void + { + DB::table('artwork_embeddings') + ->where('artwork_id', $artworkId) + ->delete(); + } +} diff --git a/app/Services/Recommendations/VectorSimilarity/PineconeAdapter.php b/app/Services/Recommendations/VectorSimilarity/PineconeAdapter.php new file mode 100644 index 00000000..4cefb311 --- /dev/null +++ b/app/Services/Recommendations/VectorSimilarity/PineconeAdapter.php @@ -0,0 +1,149 @@ + $this->apiKey(), + 'Content-Type' => 'application/json', + ])->timeout(10)->post("{$this->host()}/query", array_filter([ + 'id' => $vectorId, + 'topK' => $effectiveTopK + 1, // +1 to exclude self + 'includeMetadata' => true, + 'namespace' => $this->namespace() ?: null, + 'filter' => [ + 'is_active' => ['$eq' => true], + ], + ])); + + if (! $response->successful()) { + Log::warning("[PineconeAdapter] Query failed: HTTP {$response->status()}"); + return []; + } + + $matches = $response->json('matches', []); + + $results = []; + foreach ($matches as $match) { + $matchId = $match['id'] ?? ''; + // Extract artwork ID from "artwork:123" format + if (! str_starts_with($matchId, 'artwork:')) { + continue; + } + $matchArtworkId = (int) substr($matchId, 8); + if ($matchArtworkId === $artworkId) { + continue; // skip self + } + + $results[] = [ + 'artwork_id' => $matchArtworkId, + 'score' => (float) ($match['score'] ?? 0.0), + ]; + } + + return $results; + } catch (\Throwable $e) { + Log::warning("[PineconeAdapter] Query exception: {$e->getMessage()}"); + return []; + } + } + + public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void + { + $vectorId = "artwork:{$artworkId}"; + + // Spec §9B: metadata should include category_id, content_type, author_id, is_active, nsfw + $pineconeMetadata = array_merge([ + 'is_active' => true, + 'category_id' => $metadata['category_id'] ?? null, + 'content_type' => $metadata['content_type'] ?? null, + 'author_id' => $metadata['author_id'] ?? null, + 'nsfw' => $metadata['nsfw'] ?? false, + ], array_diff_key($metadata, array_flip([ + 'category_id', 'content_type', 'author_id', 'nsfw', 'is_active', + ]))); + + // Remove null values (Pinecone doesn't accept nulls in metadata) + $pineconeMetadata = array_filter($pineconeMetadata, fn ($v) => $v !== null); + + try { + $response = Http::withHeaders([ + 'Api-Key' => $this->apiKey(), + 'Content-Type' => 'application/json', + ])->timeout(10)->post("{$this->host()}/vectors/upsert", array_filter([ + 'vectors' => [ + [ + 'id' => $vectorId, + 'values' => array_map('floatval', $embedding), + 'metadata' => $pineconeMetadata, + ], + ], + 'namespace' => $this->namespace() ?: null, + ])); + + if (! $response->successful()) { + Log::warning("[PineconeAdapter] Upsert failed for artwork {$artworkId}: HTTP {$response->status()}"); + } + } catch (\Throwable $e) { + Log::warning("[PineconeAdapter] Upsert exception for artwork {$artworkId}: {$e->getMessage()}"); + } + } + + public function deleteEmbedding(int $artworkId): void + { + $vectorId = "artwork:{$artworkId}"; + + try { + Http::withHeaders([ + 'Api-Key' => $this->apiKey(), + 'Content-Type' => 'application/json', + ])->timeout(10)->post("{$this->host()}/vectors/delete", array_filter([ + 'ids' => [$vectorId], + 'namespace' => $this->namespace() ?: null, + ])); + } catch (\Throwable $e) { + Log::warning("[PineconeAdapter] Delete exception for artwork {$artworkId}: {$e->getMessage()}"); + } + } +} diff --git a/app/Services/Recommendations/VectorSimilarity/VectorAdapterFactory.php b/app/Services/Recommendations/VectorSimilarity/VectorAdapterFactory.php new file mode 100644 index 00000000..7e56d94c --- /dev/null +++ b/app/Services/Recommendations/VectorSimilarity/VectorAdapterFactory.php @@ -0,0 +1,37 @@ + new PgvectorAdapter(), + 'pinecone' => new PineconeAdapter(), + default => self::fallback($adapter), + }; + } + + private static function fallback(string $adapter): PgvectorAdapter + { + Log::warning("[VectorAdapterFactory] Unknown adapter '{$adapter}', falling back to pgvector."); + return new PgvectorAdapter(); + } +} diff --git a/app/Services/Recommendations/VectorSimilarity/VectorAdapterInterface.php b/app/Services/Recommendations/VectorSimilarity/VectorAdapterInterface.php new file mode 100644 index 00000000..17405765 --- /dev/null +++ b/app/Services/Recommendations/VectorSimilarity/VectorAdapterInterface.php @@ -0,0 +1,37 @@ + Ordered by score descending + */ + public function querySimilar(int $artworkId, int $topK = 100): array; + + /** + * Upsert an artwork embedding into the vector store. + * + * @param int $artworkId + * @param array $embedding Raw float vector + * @param array $metadata Optional metadata (category, author, etc.) + */ + public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void; + + /** + * Delete an artwork embedding from the vector store. + */ + public function deleteEmbedding(int $artworkId): void; +} diff --git a/app/Services/Studio/StudioArtworkQueryService.php b/app/Services/Studio/StudioArtworkQueryService.php new file mode 100644 index 00000000..3e87139d --- /dev/null +++ b/app/Services/Studio/StudioArtworkQueryService.php @@ -0,0 +1,209 @@ +listViaDatabase($userId, $filters, $perPage); + } + + try { + return $this->listViaMeilisearch($userId, $filters, $perPage); + } catch (\Throwable $e) { + Log::warning('Studio: Meilisearch unavailable, falling back to DB', [ + 'error' => $e->getMessage(), + ]); + return $this->listViaDatabase($userId, $filters, $perPage); + } + } + + private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator + { + $q = $filters['q'] ?? ''; + $filterParts = ["author_id = {$userId}"]; + $sort = []; + + // Status filter + $status = $filters['status'] ?? null; + if ($status === 'published') { + $filterParts[] = 'is_public = true AND is_approved = true'; + } elseif ($status === 'draft') { + $filterParts[] = 'is_public = false'; + } + // archived handled at DB level since Meili doesn't see soft-deleted + + // Category filter + if (!empty($filters['category'])) { + $filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"'; + } + + // Tag filter + if (!empty($filters['tags'])) { + foreach ((array) $filters['tags'] as $tag) { + $filterParts[] = 'tags = "' . addslashes((string) $tag) . '"'; + } + } + + // Date range + if (!empty($filters['date_from'])) { + $filterParts[] = 'created_at >= "' . $filters['date_from'] . '"'; + } + if (!empty($filters['date_to'])) { + $filterParts[] = 'created_at <= "' . $filters['date_to'] . '"'; + } + + // Performance quick filters + if (!empty($filters['performance'])) { + match ($filters['performance']) { + 'rising' => $filterParts[] = 'heat_score > 5', + 'top' => $filterParts[] = 'ranking_score > 50', + 'low' => $filterParts[] = 'views < 10', + default => null, + }; + } + + // Sort + $sortParam = $filters['sort'] ?? 'created_at:desc'; + $validSortFields = [ + 'created_at', 'ranking_score', 'heat_score', + 'views', 'likes', 'shares_count', + 'downloads', 'comments_count', 'favorites_count', + ]; + $parts = explode(':', $sortParam); + if (count($parts) === 2 && in_array($parts[0], $validSortFields, true)) { + $sort[] = $parts[0] . ':' . ($parts[1] === 'asc' ? 'asc' : 'desc'); + } + + $options = ['filter' => implode(' AND ', $filterParts)]; + if ($sort !== []) { + $options['sort'] = $sort; + } + + return Artwork::search($q ?: '') + ->options($options) + ->query(fn (Builder $query) => $query + ->with(['stats', 'categories', 'tags']) + ->withCount(['comments', 'downloads']) + ) + ->paginate($perPage); + } + + private function listViaDatabase(int $userId, array $filters, int $perPage): LengthAwarePaginator + { + $query = Artwork::where('user_id', $userId) + ->with(['stats', 'categories', 'tags']) + ->withCount(['comments', 'downloads']); + + $status = $filters['status'] ?? null; + if ($status === 'published') { + $query->where('is_public', true)->where('is_approved', true); + } elseif ($status === 'draft') { + $query->where('is_public', false); + } elseif ($status === 'archived') { + $query->onlyTrashed(); + } else { + // Show all except archived by default + $query->whereNull('deleted_at'); + } + + // Free-text search + if (!empty($filters['q'])) { + $q = $filters['q']; + $query->where(function (Builder $w) use ($q) { + $w->where('title', 'LIKE', "%{$q}%") + ->orWhereHas('tags', fn (Builder $t) => $t->where('slug', 'LIKE', "%{$q}%")); + }); + } + + // Category + if (!empty($filters['category'])) { + $query->whereHas('categories', fn (Builder $c) => $c->where('slug', $filters['category'])); + } + + // Tags + if (!empty($filters['tags'])) { + foreach ((array) $filters['tags'] as $tag) { + $query->whereHas('tags', fn (Builder $t) => $t->where('slug', $tag)); + } + } + + // Date range + if (!empty($filters['date_from'])) { + $query->where('created_at', '>=', $filters['date_from']); + } + if (!empty($filters['date_to'])) { + $query->where('created_at', '<=', $filters['date_to']); + } + + // Performance + if (!empty($filters['performance'])) { + $query->whereHas('stats', function (Builder $s) use ($filters) { + match ($filters['performance']) { + 'rising' => $s->where('heat_score', '>', 5), + 'top' => $s->where('ranking_score', '>', 50), + 'low' => $s->where('views', '<', 10), + default => null, + }; + }); + } + + // Sort + $sortParam = $filters['sort'] ?? 'created_at:desc'; + $parts = explode(':', $sortParam); + $sortField = $parts[0] ?? 'created_at'; + $sortDir = ($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc'; + + $dbSortMap = [ + 'created_at' => 'artworks.created_at', + 'ranking_score' => 'ranking_score', + 'heat_score' => 'heat_score', + 'views' => 'views', + 'likes' => 'favorites', + 'shares_count' => 'shares_count', + 'downloads' => 'downloads', + 'comments_count' => 'comments_count', + 'favorites_count' => 'favorites', + ]; + + $statsSortFields = ['ranking_score', 'heat_score', 'views', 'likes', 'shares_count', 'downloads', 'comments_count', 'favorites_count']; + + if (in_array($sortField, $statsSortFields, true)) { + $dbCol = $dbSortMap[$sortField] ?? $sortField; + $query->leftJoin('artwork_stats', 'artworks.id', '=', 'artwork_stats.artwork_id') + ->orderBy("artwork_stats.{$dbCol}", $sortDir) + ->select('artworks.*'); + } else { + $query->orderBy($dbSortMap[$sortField] ?? 'artworks.created_at', $sortDir); + } + + return $query->paginate($perPage); + } +} diff --git a/app/Services/Studio/StudioBulkActionService.php b/app/Services/Studio/StudioBulkActionService.php new file mode 100644 index 00000000..7a3ec619 --- /dev/null +++ b/app/Services/Studio/StudioBulkActionService.php @@ -0,0 +1,165 @@ + 0, 'failed' => 0, 'errors' => []]; + + // Validate ownership — fetch only artworks belonging to this user + $query = Artwork::where('user_id', $userId); + if ($action === 'unarchive') { + $query->onlyTrashed(); + } + $artworks = $query->whereIn('id', $artworkIds)->get(); + + $foundIds = $artworks->pluck('id')->all(); + $missingIds = array_diff($artworkIds, $foundIds); + foreach ($missingIds as $id) { + $result['failed']++; + $result['errors'][] = "Artwork #{$id}: not found or not owned by you"; + } + + if ($artworks->isEmpty()) { + return $result; + } + + DB::beginTransaction(); + + try { + foreach ($artworks as $artwork) { + $this->applyAction($artwork, $action, $params); + $result['success']++; + } + + DB::commit(); + + // Reindex affected artworks in Meilisearch + $this->reindexArtworks($artworks); + + Log::info('Studio bulk action completed', [ + 'user_id' => $userId, + 'action' => $action, + 'count' => $result['success'], + 'ids' => $foundIds, + ]); + } catch (\Throwable $e) { + DB::rollBack(); + $result['failed'] += $result['success']; + $result['success'] = 0; + $result['errors'][] = 'Transaction failed: ' . $e->getMessage(); + + Log::error('Studio bulk action failed', [ + 'user_id' => $userId, + 'action' => $action, + 'error' => $e->getMessage(), + ]); + } + + return $result; + } + + private function applyAction(Artwork $artwork, string $action, array $params): void + { + match ($action) { + 'publish' => $this->publish($artwork), + 'unpublish' => $this->unpublish($artwork), + 'archive' => $artwork->delete(), // Soft delete + 'unarchive' => $artwork->restore(), + 'delete' => $artwork->forceDelete(), + 'change_category' => $this->changeCategory($artwork, $params), + 'add_tags' => $this->addTags($artwork, $params), + 'remove_tags' => $this->removeTags($artwork, $params), + default => throw new \InvalidArgumentException("Unknown action: {$action}"), + }; + } + + private function publish(Artwork $artwork): void + { + $artwork->update([ + 'is_public' => true, + 'published_at' => $artwork->published_at ?? now(), + ]); + } + + private function unpublish(Artwork $artwork): void + { + $artwork->update(['is_public' => false]); + } + + private function changeCategory(Artwork $artwork, array $params): void + { + if (empty($params['category_id'])) { + throw new \InvalidArgumentException('category_id required for change_category'); + } + + $artwork->categories()->sync([(int) $params['category_id']]); + } + + private function addTags(Artwork $artwork, array $params): void + { + if (empty($params['tag_ids'])) { + throw new \InvalidArgumentException('tag_ids required for add_tags'); + } + + $pivotData = []; + foreach ((array) $params['tag_ids'] as $tagId) { + $pivotData[(int) $tagId] = ['source' => 'studio_bulk', 'confidence' => 1.0]; + } + + $artwork->tags()->syncWithoutDetaching($pivotData); + + // Increment usage counts + Tag::whereIn('id', array_keys($pivotData)) + ->increment('usage_count'); + } + + private function removeTags(Artwork $artwork, array $params): void + { + if (empty($params['tag_ids'])) { + throw new \InvalidArgumentException('tag_ids required for remove_tags'); + } + + $tagIds = array_map('intval', (array) $params['tag_ids']); + $artwork->tags()->detach($tagIds); + + Tag::whereIn('id', $tagIds) + ->where('usage_count', '>', 0) + ->decrement('usage_count'); + } + + /** + * Trigger Meilisearch reindex for the given artworks. + */ + private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void + { + try { + $artworks->each->searchable(); + } catch (\Throwable $e) { + Log::warning('Studio: Failed to reindex artworks after bulk action', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Studio/StudioMetricsService.php b/app/Services/Studio/StudioMetricsService.php new file mode 100644 index 00000000..b1e529ed --- /dev/null +++ b/app/Services/Studio/StudioMetricsService.php @@ -0,0 +1,229 @@ +whereNull('deleted_at') + ->count(); + + // Aggregate stats from artwork_stats for this user's artworks + $statsAgg = DB::table('artwork_stats') + ->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id') + ->where('artworks.user_id', $userId) + ->whereNull('artworks.deleted_at') + ->selectRaw(' + COALESCE(SUM(artwork_stats.views), 0) as total_views, + COALESCE(SUM(artwork_stats.favorites), 0) as total_favourites, + COALESCE(SUM(artwork_stats.shares_count), 0) as total_shares + ') + ->first(); + + // Views in last 30 days from hourly snapshots if available, fallback to totals + $views30d = 0; + try { + if (\Illuminate\Support\Facades\Schema::hasTable('artwork_metric_snapshots_hourly')) { + $views30d = (int) DB::table('artwork_metric_snapshots_hourly') + ->join('artworks', 'artworks.id', '=', 'artwork_metric_snapshots_hourly.artwork_id') + ->where('artworks.user_id', $userId) + ->where('artwork_metric_snapshots_hourly.bucket_hour', '>=', now()->subDays(30)) + ->sum('artwork_metric_snapshots_hourly.views_count'); + } + } catch (\Throwable $e) { + // Table or column doesn't exist — fall back to totals + } + + if ($views30d === 0) { + $views30d = (int) ($statsAgg->total_views ?? 0); + } + + $followers = DB::table('user_followers') + ->where('user_id', $userId) + ->count(); + + return [ + 'total_artworks' => $totalArtworks, + 'views_30d' => $views30d, + 'favourites_30d' => (int) ($statsAgg->total_favourites ?? 0), + 'shares_30d' => (int) ($statsAgg->total_shares ?? 0), + 'followers' => $followers, + ]; + }); + } + + /** + * Get top performing artworks for a creator in the last 7 days. + * + * @return \Illuminate\Support\Collection + */ + public function getTopPerformers(int $userId, int $limit = 6): \Illuminate\Support\Collection + { + $cacheKey = "studio.top_performers.{$userId}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId, $limit) { + return Artwork::where('user_id', $userId) + ->whereNull('deleted_at') + ->where('is_public', true) + ->with(['stats', 'tags']) + ->whereHas('stats') + ->orderByDesc( + ArtworkStats::select('heat_score') + ->whereColumn('artwork_stats.artwork_id', 'artworks.id') + ->limit(1) + ) + ->limit($limit) + ->get() + ->map(fn (Artwork $art) => [ + 'id' => $art->id, + 'title' => $art->title, + 'slug' => $art->slug, + 'thumb_url' => $art->thumbUrl('md'), + 'favourites' => (int) ($art->stats?->favorites ?? 0), + 'shares' => (int) ($art->stats?->shares_count ?? 0), + 'heat_score' => (float) ($art->stats?->heat_score ?? 0), + 'ranking_score' => (float) ($art->stats?->ranking_score ?? 0), + ]); + }); + } + + /** + * Get recent comments on a creator's artworks. + * + * @return \Illuminate\Support\Collection + */ + public function getRecentComments(int $userId, int $limit = 5): \Illuminate\Support\Collection + { + return DB::table('artwork_comments') + ->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id') + ->join('users', 'users.id', '=', 'artwork_comments.user_id') + ->where('artworks.user_id', $userId) + ->whereNull('artwork_comments.deleted_at') + ->orderByDesc('artwork_comments.created_at') + ->limit($limit) + ->select([ + 'artwork_comments.id', + 'artwork_comments.content as body', + 'artwork_comments.created_at', + 'users.name as author_name', + 'users.username as author_username', + 'artworks.title as artwork_title', + 'artworks.slug as artwork_slug', + ]) + ->get(); + } + + /** + * Aggregate analytics across all artworks for the Studio Analytics page. + * + * @return array{totals: array, top_artworks: array, content_breakdown: array} + */ + public function getAnalyticsOverview(int $userId): array + { + $cacheKey = "studio.analytics_overview.{$userId}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) { + // Totals + $totals = DB::table('artwork_stats') + ->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id') + ->where('artworks.user_id', $userId) + ->whereNull('artworks.deleted_at') + ->selectRaw(' + COALESCE(SUM(artwork_stats.views), 0) as views, + COALESCE(SUM(artwork_stats.favorites), 0) as favourites, + COALESCE(SUM(artwork_stats.shares_count), 0) as shares, + COALESCE(SUM(artwork_stats.downloads), 0) as downloads, + COALESCE(SUM(artwork_stats.comments_count), 0) as comments, + COALESCE(AVG(artwork_stats.ranking_score), 0) as avg_ranking, + COALESCE(AVG(artwork_stats.heat_score), 0) as avg_heat + ') + ->first(); + + // Top 10 artworks by ranking score + $topArtworks = Artwork::where('user_id', $userId) + ->whereNull('deleted_at') + ->where('is_public', true) + ->with(['stats']) + ->whereHas('stats') + ->orderByDesc( + ArtworkStats::select('ranking_score') + ->whereColumn('artwork_stats.artwork_id', 'artworks.id') + ->limit(1) + ) + ->limit(10) + ->get() + ->map(fn (Artwork $art) => [ + 'id' => $art->id, + 'title' => $art->title, + 'slug' => $art->slug, + 'thumb_url' => $art->thumbUrl('sq'), + 'views' => (int) ($art->stats?->views ?? 0), + 'favourites' => (int) ($art->stats?->favorites ?? 0), + 'shares' => (int) ($art->stats?->shares_count ?? 0), + 'downloads' => (int) ($art->stats?->downloads ?? 0), + 'comments' => (int) ($art->stats?->comments_count ?? 0), + 'ranking_score' => (float) ($art->stats?->ranking_score ?? 0), + 'heat_score' => (float) ($art->stats?->heat_score ?? 0), + ]); + + // Content type breakdown + $contentBreakdown = DB::table('artworks') + ->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id') + ->join('categories', 'categories.id', '=', 'artwork_category.category_id') + ->join('content_types', 'content_types.id', '=', 'categories.content_type_id') + ->where('artworks.user_id', $userId) + ->whereNull('artworks.deleted_at') + ->groupBy('content_types.id', 'content_types.name', 'content_types.slug') + ->select([ + 'content_types.name', + 'content_types.slug', + DB::raw('COUNT(DISTINCT artworks.id) as count'), + ]) + ->orderByDesc('count') + ->get() + ->map(fn ($row) => [ + 'name' => $row->name, + 'slug' => $row->slug, + 'count' => (int) $row->count, + ]) + ->values() + ->all(); + + return [ + 'totals' => [ + 'views' => (int) ($totals->views ?? 0), + 'favourites' => (int) ($totals->favourites ?? 0), + 'shares' => (int) ($totals->shares ?? 0), + 'downloads' => (int) ($totals->downloads ?? 0), + 'comments' => (int) ($totals->comments ?? 0), + 'avg_ranking' => round((float) ($totals->avg_ranking ?? 0), 1), + 'avg_heat' => round((float) ($totals->avg_heat ?? 0), 1), + ], + 'top_artworks' => $topArtworks->values()->all(), + 'content_breakdown' => $contentBreakdown, + ]; + }); + } +} diff --git a/app/Services/TagService.php b/app/Services/TagService.php index b3d10191..405a3e4c 100644 --- a/app/Services/TagService.php +++ b/app/Services/TagService.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Services; use App\Jobs\IndexArtworkJob; +use App\Jobs\RecComputeSimilarByTagsJob; +use App\Jobs\RecComputeSimilarHybridJob; use App\Models\Artwork; use App\Models\Tag; use App\Services\TagNormalizer; @@ -346,5 +348,12 @@ final class TagService private function queueReindex(Artwork $artwork): void { IndexArtworkJob::dispatch($artwork->id); + + // §7.5 On-demand: recompute tag/hybrid similarity when tags change. + // Pivot syncs don't trigger the Artwork "updated" event, so we dispatch here. + if ($artwork->is_public && $artwork->published_at) { + RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30)); + RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinute()); + } } } diff --git a/config/features.php b/config/features.php index 103810c7..00611cf2 100644 --- a/config/features.php +++ b/config/features.php @@ -2,4 +2,5 @@ return [ 'uploads_v2' => (bool) env('SKINBASE_UPLOADS_V2', true), + 'similarity_vector' => (bool) env('SIMILARITY_VECTOR_ENABLED', false), ]; diff --git a/config/recommendations.php b/config/recommendations.php index 430386ee..4c5d3b48 100644 --- a/config/recommendations.php +++ b/config/recommendations.php @@ -89,4 +89,47 @@ return [ explode(',', (string) env('RECOMMENDATIONS_AB_ALGO_VERSIONS', env('RECOMMENDATIONS_ALGO_VERSION', 'clip-cosine-v1'))) ))), ], + + // ─── Similar Artworks (hybrid recommender) ───────────────────────────────── + 'similarity' => [ + 'model_version' => env('SIMILARITY_MODEL_VERSION', 'sim_v1'), + + // Vector DB integration (behind feature flag) + 'vector_enabled' => (bool) env('SIMILARITY_VECTOR_ENABLED', false), + 'vector_adapter' => env('SIMILARITY_VECTOR_ADAPTER', 'pgvector'), // pgvector | pinecone + + // Hybrid blend weights (spec §5.4) + 'weights_with_vector' => [ + 'visual' => (float) env('SIM_W_VISUAL', 0.45), + 'tag' => (float) env('SIM_W_TAG_VEC', 0.25), + 'behavior' => (float) env('SIM_W_BEH_VEC', 0.20), + 'category' => (float) env('SIM_W_CAT_VEC', 0.10), + ], + 'weights_without_vector' => [ + 'tag' => (float) env('SIM_W_TAG', 0.55), + 'behavior' => (float) env('SIM_W_BEH', 0.35), + 'category' => (float) env('SIM_W_CAT', 0.10), + ], + + // Diversity caps (spec §6) + 'max_per_author' => (int) env('SIM_MAX_PER_AUTHOR', 2), + 'result_limit' => (int) env('SIM_RESULT_LIMIT', 30), + 'candidate_pool' => (int) env('SIM_CANDIDATE_POOL', 100), + 'min_categories_top12' => (int) env('SIM_MIN_CATS_TOP12', 2), + + // Behavior pair building + 'user_favourites_cap' => (int) env('SIM_USER_FAV_CAP', 50), + + // Cache TTL for precomputed lists (sec) + 'cache_ttl' => (int) env('SIM_CACHE_TTL', 6 * 3600), + + // Pinecone adapter settings + 'pinecone' => [ + 'api_key' => env('PINECONE_API_KEY'), + 'index_host' => env('PINECONE_INDEX_HOST'), + 'index_name' => env('PINECONE_INDEX_NAME', 'skinbase-artworks'), + 'namespace' => env('PINECONE_NAMESPACE', ''), + 'top_k' => (int) env('PINECONE_TOP_K', 100), + ], + ], ]; diff --git a/config/scout.php b/config/scout.php index 0e3e1230..23103f06 100644 --- a/config/scout.php +++ b/config/scout.php @@ -92,6 +92,7 @@ return [ 'description', ], 'filterableAttributes' => [ + 'id', 'tags', 'category', 'content_type', @@ -116,6 +117,7 @@ return [ 'shares_count', 'engagement_velocity', 'comments_count', + 'heat_score', ], 'rankingRules' => [ 'words', diff --git a/database/migrations/2026_02_28_200000_create_artwork_metric_snapshots_hourly_table.php b/database/migrations/2026_02_28_200000_create_artwork_metric_snapshots_hourly_table.php new file mode 100644 index 00000000..3bfa0dc5 --- /dev/null +++ b/database/migrations/2026_02_28_200000_create_artwork_metric_snapshots_hourly_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->unsignedBigInteger('artwork_id'); + $table->dateTime('bucket_hour')->comment('Hour-precision bucket, e.g. 2026-02-28 14:00:00'); + $table->unsignedBigInteger('views_count')->default(0); + $table->unsignedBigInteger('downloads_count')->default(0); + $table->unsignedBigInteger('favourites_count')->default(0); + $table->unsignedBigInteger('comments_count')->default(0); + $table->unsignedBigInteger('shares_count')->default(0); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['artwork_id', 'bucket_hour'], 'uq_artwork_bucket'); + $table->index('bucket_hour', 'idx_bucket_hour'); + $table->index(['artwork_id', 'bucket_hour'], 'idx_artwork_bucket'); + + $table->foreign('artwork_id') + ->references('id') + ->on('artworks') + ->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_metric_snapshots_hourly'); + } +}; diff --git a/database/migrations/2026_02_28_200001_add_heat_score_columns_to_artwork_stats.php b/database/migrations/2026_02_28_200001_add_heat_score_columns_to_artwork_stats.php new file mode 100644 index 00000000..7c4a153d --- /dev/null +++ b/database/migrations/2026_02_28_200001_add_heat_score_columns_to_artwork_stats.php @@ -0,0 +1,39 @@ +double('heat_score')->default(0)->after('engagement_velocity'); + $table->timestamp('heat_score_updated_at')->nullable()->after('heat_score'); + $table->unsignedInteger('views_1h')->default(0)->after('heat_score_updated_at'); + $table->unsignedInteger('favourites_1h')->default(0)->after('views_1h'); + $table->unsignedInteger('comments_1h')->default(0)->after('favourites_1h'); + $table->unsignedInteger('shares_1h')->default(0)->after('comments_1h'); + $table->unsignedInteger('downloads_1h')->default(0)->after('shares_1h'); + + $table->index('heat_score', 'idx_artwork_stats_heat_score'); + }); + } + + public function down(): void + { + Schema::table('artwork_stats', function (Blueprint $table) { + $table->dropIndex('idx_artwork_stats_heat_score'); + $table->dropColumn([ + 'heat_score', + 'heat_score_updated_at', + 'views_1h', + 'favourites_1h', + 'comments_1h', + 'shares_1h', + 'downloads_1h', + ]); + }); + } +}; diff --git a/database/migrations/2026_02_28_300000_create_rec_artwork_recs_table.php b/database/migrations/2026_02_28_300000_create_rec_artwork_recs_table.php new file mode 100644 index 00000000..0009b0b8 --- /dev/null +++ b/database/migrations/2026_02_28_300000_create_rec_artwork_recs_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('artwork_id'); + $table->string('rec_type', 40); // similar_hybrid, similar_visual, similar_tags, similar_behavior + $table->json('recs'); // ordered array of artwork_ids + $table->string('model_version', 30)->default('sim_v1'); + $table->dateTime('computed_at'); + $table->timestamps(); + + $table->unique(['artwork_id', 'rec_type', 'model_version']); + $table->index(['artwork_id', 'rec_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('rec_artwork_recs'); + } +}; diff --git a/database/migrations/2026_02_28_300001_create_rec_item_pairs_table.php b/database/migrations/2026_02_28_300001_create_rec_item_pairs_table.php new file mode 100644 index 00000000..2ac4b2d5 --- /dev/null +++ b/database/migrations/2026_02_28_300001_create_rec_item_pairs_table.php @@ -0,0 +1,29 @@ +unsignedBigInteger('a_artwork_id'); + $table->unsignedBigInteger('b_artwork_id'); + $table->double('weight')->default(0); + $table->dateTime('updated_at'); + + $table->unique(['a_artwork_id', 'b_artwork_id']); + $table->index(['a_artwork_id', 'weight']); + $table->index('b_artwork_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('rec_item_pairs'); + } +}; diff --git a/database/migrations/2026_02_28_300002_create_rec_events_table.php b/database/migrations/2026_02_28_300002_create_rec_events_table.php new file mode 100644 index 00000000..8200d646 --- /dev/null +++ b/database/migrations/2026_02_28_300002_create_rec_events_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('session_id', 80)->nullable(); + $table->string('event_type', 20); // view, favourite, download + $table->unsignedBigInteger('artwork_id'); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['artwork_id', 'created_at']); + $table->index(['user_id', 'created_at']); + $table->index(['session_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('rec_events'); + } +}; diff --git a/resources/js/Pages/Home/HomePage.jsx b/resources/js/Pages/Home/HomePage.jsx index c7c52323..78289bc3 100644 --- a/resources/js/Pages/Home/HomePage.jsx +++ b/resources/js/Pages/Home/HomePage.jsx @@ -11,6 +11,7 @@ const HomeTrendingForYou = lazy(() => import('./HomeTrendingForYou')) const HomeBecauseYouLike = lazy(() => import('./HomeBecauseYouLike')) const HomeSuggestedCreators = lazy(() => import('./HomeSuggestedCreators')) const HomeTrending = lazy(() => import('./HomeTrending')) +const HomeRising = lazy(() => import('./HomeRising')) const HomeFresh = lazy(() => import('./HomeFresh')) const HomeCategories = lazy(() => import('./HomeCategories')) const HomeTags = lazy(() => import('./HomeTags')) @@ -25,12 +26,15 @@ function SectionFallback() { } function GuestHomePage(props) { - const { hero, trending, fresh, tags, creators, news } = props + const { hero, rising, trending, fresh, tags, creators, news } = props return ( <> {/* 1. Hero */} + }> + + }> @@ -73,6 +77,7 @@ function AuthHomePage(props) { user_data, hero, from_following, + rising, trending, fresh, by_tags, @@ -104,6 +109,11 @@ function AuthHomePage(props) { + {/* Rising Now */} + }> + + + {/* 2. Global Trending Now */} }> diff --git a/resources/js/Pages/Home/HomeRising.jsx b/resources/js/Pages/Home/HomeRising.jsx new file mode 100644 index 00000000..dff8f777 --- /dev/null +++ b/resources/js/Pages/Home/HomeRising.jsx @@ -0,0 +1,85 @@ +import React from 'react' + +const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' + +function ArtCard({ item }) { + const username = item.author_username ? `@${item.author_username}` : null + + return ( + + ) +} + +export default function HomeRising({ items }) { + if (!Array.isArray(items) || items.length === 0) return null + + return ( +
+
+

+ 🚀 Rising Now +

+ + See all → + +
+ +
+ {items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => ( + + ))} +
+
+ ) +} diff --git a/resources/js/Pages/Studio/StudioAnalytics.jsx b/resources/js/Pages/Studio/StudioAnalytics.jsx new file mode 100644 index 00000000..a06178b3 --- /dev/null +++ b/resources/js/Pages/Studio/StudioAnalytics.jsx @@ -0,0 +1,213 @@ +import React from 'react' +import { usePage, Link } from '@inertiajs/react' +import StudioLayout from '../../Layouts/StudioLayout' + +const kpiItems = [ + { key: 'views', label: 'Total Views', icon: 'fa-eye', color: 'text-emerald-400', bg: 'bg-emerald-500/10' }, + { key: 'favourites', label: 'Total Favourites', icon: 'fa-heart', color: 'text-pink-400', bg: 'bg-pink-500/10' }, + { key: 'shares', label: 'Total Shares', icon: 'fa-share-nodes', color: 'text-amber-400', bg: 'bg-amber-500/10' }, + { key: 'downloads', label: 'Total Downloads', icon: 'fa-download', color: 'text-purple-400', bg: 'bg-purple-500/10' }, + { key: 'comments', label: 'Total Comments', icon: 'fa-comment', color: 'text-blue-400', bg: 'bg-blue-500/10' }, +] + +const performanceItems = [ + { key: 'avg_ranking', label: 'Avg Ranking Score', icon: 'fa-trophy', color: 'text-yellow-400', bg: 'bg-yellow-500/10' }, + { key: 'avg_heat', label: 'Avg Heat Score', icon: 'fa-fire', color: 'text-orange-400', bg: 'bg-orange-500/10' }, +] + +const contentTypeIcons = { + skins: 'fa-layer-group', + wallpapers: 'fa-desktop', + photography: 'fa-camera', + other: 'fa-folder-open', + members: 'fa-users', +} + +const contentTypeColors = { + skins: 'text-emerald-400 bg-emerald-500/10', + wallpapers: 'text-blue-400 bg-blue-500/10', + photography: 'text-amber-400 bg-amber-500/10', + other: 'text-slate-400 bg-slate-500/10', + members: 'text-purple-400 bg-purple-500/10', +} + +export default function StudioAnalytics() { + const { props } = usePage() + const { totals, topArtworks, contentBreakdown, recentComments } = props + + const totalArtworksCount = (contentBreakdown || []).reduce((sum, ct) => sum + ct.count, 0) + + return ( + + {/* KPI Cards */} +
+ {kpiItems.map((item) => ( +
+
+
+ +
+ {item.label} +
+

+ {(totals?.[item.key] ?? 0).toLocaleString()} +

+
+ ))} +
+ + {/* Performance Averages */} +
+ {performanceItems.map((item) => ( +
+
+
+ +
+ {item.label} +
+

+ {(totals?.[item.key] ?? 0).toFixed(1)} +

+
+ ))} +
+ +
+ {/* Content Breakdown */} +
+

+ + Content Breakdown +

+ {contentBreakdown?.length > 0 ? ( +
+ {contentBreakdown.map((ct) => { + const pct = totalArtworksCount > 0 ? Math.round((ct.count / totalArtworksCount) * 100) : 0 + const iconClass = contentTypeIcons[ct.slug] || 'fa-folder' + const colorClass = contentTypeColors[ct.slug] || 'text-slate-400 bg-slate-500/10' + const [textColor, bgColor] = colorClass.split(' ') + return ( +
+
+ +
+
+
+ {ct.name} + {ct.count} +
+
+
+
+
+
+ ) + })} +
+ ) : ( +

No artworks categorised yet

+ )} +
+ + {/* Recent Comments */} +
+

+ + Recent Comments +

+ {recentComments?.length > 0 ? ( +
+ {recentComments.map((c) => ( +
+
+ +
+
+

+ {c.author_name} + {' '}on{' '} + {c.artwork_title} +

+

{c.body}

+

{new Date(c.created_at).toLocaleDateString()}

+
+
+ ))} +
+ ) : ( +

No comments yet

+ )} +
+
+ + {/* Top Performers Table */} +
+

+ + Top 10 Artworks +

+ {topArtworks?.length > 0 ? ( +
+ + + + + + + + + + + + + + + {topArtworks.map((art, i) => ( + + + + + + + + + + + ))} + +
#ArtworkViewsFavsSharesDownloadsRankingHeat
{i + 1} + + {art.thumb_url && ( + {art.title} + )} + + {art.title} + + + {art.views.toLocaleString()}{art.favourites.toLocaleString()}{art.shares.toLocaleString()}{art.downloads.toLocaleString()}{art.ranking_score.toFixed(1)} + 5 ? 'text-orange-400' : 'text-slate-400'}`}> + {art.heat_score.toFixed(1)} + + {art.heat_score > 5 && ( + + )} +
+
+ ) : ( +

No published artworks with stats yet

+ )} +
+ + ) +} diff --git a/resources/js/Pages/Studio/StudioArchived.jsx b/resources/js/Pages/Studio/StudioArchived.jsx new file mode 100644 index 00000000..8d036102 --- /dev/null +++ b/resources/js/Pages/Studio/StudioArchived.jsx @@ -0,0 +1,203 @@ +import React from 'react' +import { usePage } from '@inertiajs/react' +import StudioLayout from '../../Layouts/StudioLayout' +import StudioToolbar from '../../Components/Studio/StudioToolbar' +import StudioGridCard from '../../Components/Studio/StudioGridCard' +import StudioTable from '../../Components/Studio/StudioTable' +import BulkActionsBar from '../../Components/Studio/BulkActionsBar' +import BulkTagModal from '../../Components/Studio/BulkTagModal' +import BulkCategoryModal from '../../Components/Studio/BulkCategoryModal' +import ConfirmDangerModal from '../../Components/Studio/ConfirmDangerModal' + +function getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} + +export default function StudioArchived() { + const { props } = usePage() + const { categories } = props + + const [viewMode, setViewMode] = React.useState(() => localStorage.getItem('studio_view_mode') || 'grid') + const [artworks, setArtworks] = React.useState([]) + const [meta, setMeta] = React.useState({ current_page: 1, last_page: 1, per_page: 24, total: 0 }) + const [loading, setLoading] = React.useState(true) + const [search, setSearch] = React.useState('') + const [sort, setSort] = React.useState('created_at:desc') + const [selectedIds, setSelectedIds] = React.useState([]) + const [deleteModal, setDeleteModal] = React.useState({ open: false, ids: [] }) + const [tagModal, setTagModal] = React.useState({ open: false, mode: 'add' }) + const [categoryModal, setCategoryModal] = React.useState({ open: false }) + const searchTimer = React.useRef(null) + const perPage = viewMode === 'list' ? 50 : 24 + + const fetchArtworks = React.useCallback(async (page = 1) => { + setLoading(true) + try { + const params = new URLSearchParams() + params.set('page', page) + params.set('per_page', perPage) + params.set('sort', sort) + params.set('status', 'archived') + if (search) params.set('q', search) + const res = await fetch(`/api/studio/artworks?${params.toString()}`, { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + }) + const data = await res.json() + setArtworks(data.data || []) + setMeta(data.meta || meta) + } catch (err) { + console.error('Failed to fetch:', err) + } finally { + setLoading(false) + } + }, [search, sort, perPage]) + + React.useEffect(() => { + clearTimeout(searchTimer.current) + searchTimer.current = setTimeout(() => fetchArtworks(1), 300) + return () => clearTimeout(searchTimer.current) + }, [fetchArtworks]) + + const handleViewModeChange = (mode) => { + setViewMode(mode) + localStorage.setItem('studio_view_mode', mode) + } + const toggleSelect = (id) => setSelectedIds((p) => p.includes(id) ? p.filter((i) => i !== id) : [...p, id]) + const selectAll = () => { + const ids = artworks.map((a) => a.id) + setSelectedIds(ids.every((id) => selectedIds.includes(id)) ? [] : ids) + } + + const handleAction = async (action, artwork) => { + if (action === 'edit') { window.location.href = `/studio/artworks/${artwork.id}/edit`; return } + if (action === 'delete') { setDeleteModal({ open: true, ids: [artwork.id] }); return } + try { + await fetch(`/api/studio/artworks/${artwork.id}/toggle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify({ action }), + }) + fetchArtworks(meta.current_page) + } catch (err) { console.error(err) } + } + + const executeBulk = async (action) => { + if (action === 'delete') { setDeleteModal({ open: true, ids: [...selectedIds] }); return } + if (action === 'add_tags') { setTagModal({ open: true, mode: 'add' }); return } + if (action === 'remove_tags') { setTagModal({ open: true, mode: 'remove' }); return } + if (action === 'change_category') { setCategoryModal({ open: true }); return } + try { + await fetch('/api/studio/artworks/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify({ action, artwork_ids: selectedIds, params: {} }), + }) + setSelectedIds([]) + fetchArtworks(meta.current_page) + } catch (err) { console.error(err) } + } + + const confirmBulkTags = async (tagIds) => { + const action = tagModal.mode === 'add' ? 'add_tags' : 'remove_tags' + setTagModal({ open: false, mode: 'add' }) + try { + await fetch('/api/studio/artworks/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify({ action, artwork_ids: selectedIds, params: { tag_ids: tagIds } }), + }) + setSelectedIds([]) + fetchArtworks(meta.current_page) + } catch (err) { console.error(err) } + } + + const confirmBulkCategory = async (categoryId) => { + setCategoryModal({ open: false }) + try { + await fetch('/api/studio/artworks/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify({ action: 'change_category', artwork_ids: selectedIds, params: { category_id: categoryId } }), + }) + setSelectedIds([]) + fetchArtworks(meta.current_page) + } catch (err) { console.error(err) } + } + + const confirmDelete = async () => { + try { + await fetch('/api/studio/artworks/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify({ action: 'delete', artwork_ids: deleteModal.ids, confirm: 'DELETE' }), + }) + setDeleteModal({ open: false, ids: [] }) + setSelectedIds((p) => p.filter((id) => !deleteModal.ids.includes(id))) + fetchArtworks(meta.current_page) + } catch (err) { console.error(err) } + } + + return ( + + {}} + selectedCount={selectedIds.length} + /> + + {loading && ( +
+
+
+ )} + + {!loading && viewMode === 'grid' && ( +
+ {artworks.map((art) => ( + + ))} +
+ )} + + {!loading && viewMode === 'list' && ( + + )} + + {!loading && artworks.length === 0 && ( +
+ +

No archived artworks

+
+ )} + + {meta.last_page > 1 && ( +
+ {Array.from({ length: meta.last_page }, (_, i) => i + 1) + .filter((p) => p === 1 || p === meta.last_page || Math.abs(p - meta.current_page) <= 2) + .map((page, idx, arr) => ( + + {idx > 0 && arr[idx - 1] !== page - 1 && } + + + ))} +
+ )} + + setSelectedIds([])} /> + setDeleteModal({ open: false, ids: [] })} onConfirm={confirmDelete} title="Permanently delete?" message={`Delete ${deleteModal.ids.length} artwork(s) permanently?`} /> + setTagModal({ open: false, mode: 'add' })} onConfirm={confirmBulkTags} /> + setCategoryModal({ open: false })} onConfirm={confirmBulkCategory} /> + + ) +} diff --git a/resources/js/Pages/Studio/StudioArtworkAnalytics.jsx b/resources/js/Pages/Studio/StudioArtworkAnalytics.jsx new file mode 100644 index 00000000..9a432322 --- /dev/null +++ b/resources/js/Pages/Studio/StudioArtworkAnalytics.jsx @@ -0,0 +1,128 @@ +import React from 'react' +import { usePage, Link } from '@inertiajs/react' +import StudioLayout from '../../Layouts/StudioLayout' + +const kpiItems = [ + { key: 'views', label: 'Views', icon: 'fa-eye', color: 'text-emerald-400' }, + { key: 'favourites', label: 'Favourites', icon: 'fa-heart', color: 'text-pink-400' }, + { key: 'shares', label: 'Shares', icon: 'fa-share-nodes', color: 'text-amber-400' }, + { key: 'comments', label: 'Comments', icon: 'fa-comment', color: 'text-blue-400' }, + { key: 'downloads', label: 'Downloads', icon: 'fa-download', color: 'text-purple-400' }, +] + +const metricCards = [ + { key: 'ranking_score', label: 'Ranking Score', icon: 'fa-trophy', color: 'text-yellow-400' }, + { key: 'heat_score', label: 'Heat Score', icon: 'fa-fire', color: 'text-orange-400' }, + { key: 'engagement_velocity', label: 'Engagement Velocity', icon: 'fa-bolt', color: 'text-cyan-400' }, +] + +export default function StudioArtworkAnalytics() { + const { props } = usePage() + const { artwork, analytics } = props + + return ( + + {/* Back link */} + + + Back to Artworks + + + {/* Artwork header */} +
+ {artwork?.thumb_url && ( + {artwork.title} + )} +
+

{artwork?.title}

+

/{artwork?.slug}

+
+
+ + {/* KPI row */} +
+ {kpiItems.map((item) => ( +
+
+ + {item.label} +
+

+ {(analytics?.[item.key] ?? 0).toLocaleString()} +

+
+ ))} +
+ + {/* Performance metrics */} +

Performance Metrics

+
+ {metricCards.map((item) => ( +
+
+
+ +
+ {item.label} +
+

+ {(analytics?.[item.key] ?? 0).toFixed(1)} +

+
+ ))} +
+ + {/* Placeholder sections for future features */} +
+
+

+ + Traffic Sources +

+
+
+ +

Coming soon

+

Traffic source tracking is on the roadmap

+
+
+
+ +
+

+ + Shares by Platform +

+
+
+ +

Coming soon

+

Platform-level share tracking coming in v2

+
+
+
+ +
+

+ + Ranking History +

+
+
+ +

Coming soon

+

Historical ranking data will be tracked in a future update

+
+
+
+
+
+ ) +} diff --git a/resources/js/Pages/Studio/StudioArtworkEdit.jsx b/resources/js/Pages/Studio/StudioArtworkEdit.jsx new file mode 100644 index 00000000..1ffb7360 --- /dev/null +++ b/resources/js/Pages/Studio/StudioArtworkEdit.jsx @@ -0,0 +1,455 @@ +import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react' +import { usePage, Link } from '@inertiajs/react' +import StudioLayout from '../../Layouts/StudioLayout' + +function getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} + +function formatBytes(bytes) { + if (!bytes) return '—' + if (bytes < 1024) return bytes + ' B' + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / 1048576).toFixed(1) + ' MB' +} + +function getContentTypeVisualKey(slug) { + const map = { skins: 'skins', wallpapers: 'wallpapers', photography: 'photography', other: 'other', members: 'members' } + return map[slug] || 'other' +} + +function buildCategoryTree(contentTypes) { + return (contentTypes || []).map((ct) => ({ + ...ct, + rootCategories: (ct.root_categories || []).map((rc) => ({ + ...rc, + children: rc.children || [], + })), + })) +} + +export default function StudioArtworkEdit() { + const { props } = usePage() + const { artwork, contentTypes: rawContentTypes } = props + + const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes]) + + // --- State --- + const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null) + const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null) + const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null) + const [title, setTitle] = useState(artwork?.title || '') + const [description, setDescription] = useState(artwork?.description || '') + const [tags, setTags] = useState(() => (artwork?.tags || []).map((t) => ({ id: t.id, name: t.name, slug: t.slug || t.name }))) + const [isPublic, setIsPublic] = useState(artwork?.is_public ?? true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [errors, setErrors] = useState({}) + + // Tag picker state + const [tagQuery, setTagQuery] = useState('') + const [tagResults, setTagResults] = useState([]) + const [tagLoading, setTagLoading] = useState(false) + const tagInputRef = useRef(null) + const tagSearchTimer = useRef(null) + + // File replace state + const fileInputRef = useRef(null) + const [replacing, setReplacing] = useState(false) + const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null) + const [fileMeta, setFileMeta] = useState({ + name: artwork?.file_name || '—', + size: artwork?.file_size || 0, + width: artwork?.width || 0, + height: artwork?.height || 0, + }) + + // --- Tag search --- + const searchTags = useCallback(async (q) => { + setTagLoading(true) + try { + const params = new URLSearchParams() + if (q) params.set('q', q) + const res = await fetch(`/api/studio/tags/search?${params.toString()}`, { + headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + }) + const data = await res.json() + setTagResults(data || []) + } catch { + setTagResults([]) + } finally { + setTagLoading(false) + } + }, []) + + useEffect(() => { + clearTimeout(tagSearchTimer.current) + tagSearchTimer.current = setTimeout(() => searchTags(tagQuery), 250) + return () => clearTimeout(tagSearchTimer.current) + }, [tagQuery, searchTags]) + + const toggleTag = (tag) => { + setTags((prev) => { + const exists = prev.find((t) => t.id === tag.id) + return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name, slug: tag.slug }] + }) + } + + const removeTag = (id) => { + setTags((prev) => prev.filter((t) => t.id !== id)) + } + + // --- Derived data --- + const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null + const rootCategories = selectedCT?.rootCategories || [] + const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null + const subCategories = selectedRoot?.children || [] + + // --- Handlers --- + const handleContentTypeChange = (id) => { + setContentTypeId(id) + setCategoryId(null) + setSubCategoryId(null) + } + + const handleCategoryChange = (id) => { + setCategoryId(id) + setSubCategoryId(null) + } + + const handleSave = async () => { + setSaving(true) + setSaved(false) + setErrors({}) + try { + const payload = { + title, + description, + is_public: isPublic, + category_id: subCategoryId || categoryId || null, + tags: tags.map((t) => t.slug || t.name), + } + const res = await fetch(`/api/studio/artworks/${artwork.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify(payload), + }) + if (res.ok) { + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } else { + const data = await res.json() + if (data.errors) setErrors(data.errors) + console.error('Save failed:', data) + } + } catch (err) { + console.error('Save failed:', err) + } finally { + setSaving(false) + } + } + + const handleFileReplace = async (e) => { + const file = e.target.files?.[0] + if (!file) return + setReplacing(true) + try { + const fd = new FormData() + fd.append('file', file) + const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, { + method: 'POST', + headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: fd, + }) + const data = await res.json() + if (res.ok && data.thumb_url) { + setThumbUrl(data.thumb_url) + setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 }) + } else { + console.error('File replace failed:', data) + } + } catch (err) { + console.error('File replace failed:', err) + } finally { + setReplacing(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + // --- Render --- + return ( + + + + Back to Artworks + + +
+ {/* ── Uploaded Asset ── */} +
+

Uploaded Asset

+
+ {thumbUrl ? ( + {title} + ) : ( +
+ +
+ )} +
+

{fileMeta.name}

+

{formatBytes(fileMeta.size)}

+ {fileMeta.width > 0 && ( +

{fileMeta.width} × {fileMeta.height} px

+ )} + + +
+
+
+ + {/* ── Content Type ── */} +
+

Content Type

+
+ {contentTypes.map((ct) => { + const active = ct.id === contentTypeId + const vk = getContentTypeVisualKey(ct.slug) + return ( + + ) + })} +
+
+ + {/* ── Category ── */} + {rootCategories.length > 0 && ( +
+
+

Category

+
+ {rootCategories.map((cat) => { + const active = cat.id === categoryId + return ( + + ) + })} +
+
+ + {/* Subcategory */} + {subCategories.length > 0 && ( +
+

Subcategory

+
+ {subCategories.map((sub) => { + const active = sub.id === subCategoryId + return ( + + ) + })} +
+
+ )} +
+ )} + + {/* ── Basics ── */} +
+

Basics

+ +
+ + setTitle(e.target.value)} + maxLength={120} + className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" + /> + {errors.title &&

{errors.title[0]}

} +
+ +
+ +