Studio: make grid checkbox rectangular and commit table changes

This commit is contained in:
2026-03-01 08:43:48 +01:00
parent 211dc58884
commit e3ca845a6d
89 changed files with 7323 additions and 475 deletions

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Collect hourly metric snapshots for artworks.
*
* Runs on cron every hour. Inserts a row per artwork into
* artwork_metric_snapshots_hourly with the current totals.
* Deltas are computed by the heat recalculation command.
*
* Usage: php artisan nova:metrics-snapshot-hourly
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
*/
class MetricsSnapshotHourlyCommand extends Command
{
protected $signature = 'nova:metrics-snapshot-hourly
{--days=60 : Only snapshot artworks created within this many days}
{--chunk=1000 : Chunk size for DB queries}
{--dry-run : Log what would be written without persisting}';
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
public function handle(): int
{
$days = (int) $this->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;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Prune old hourly metric snapshots to prevent unbounded table growth.
*
* Usage: php artisan nova:prune-metric-snapshots
* php artisan nova:prune-metric-snapshots --keep-days=7
*/
class PruneMetricSnapshotsCommand extends Command
{
protected $signature = 'nova:prune-metric-snapshots
{--keep-days=7 : Keep snapshots for this many days}';
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
public function handle(): int
{
$keepDays = (int) $this->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;
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Recalculate heat_score for artworks based on hourly metric snapshots.
*
* Runs every 1015 minutes via scheduler.
*
* Formula:
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
* + comments_delta*8 + shares_delta*12
*
* age_factor = 1 / (1 + hours_since_upload / 24)
*
* heat_score = raw_heat * age_factor
*
* Usage: php artisan nova:recalculate-heat
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
*/
class RecalculateHeatCommand extends Command
{
protected $signature = 'nova:recalculate-heat
{--days=60 : Only process artworks created within this many days}
{--chunk=1000 : Chunk size for DB queries}
{--dry-run : Compute scores without writing to DB}';
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
/** Delta weights per the spec */
private const WEIGHTS = [
'views' => 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;
}
}

View File

@@ -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');
}
/**

View File

@@ -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 3060 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ørensenDice-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))
);
}

View File

@@ -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,
);
}
}

View File

@@ -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(),

View File

@@ -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) [

View File

@@ -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',
],

View File

@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
/**
* JSON API endpoints for the Studio artwork manager.
*/
final class StudioArtworksApiController extends Controller
{
public function __construct(
private readonly StudioArtworkQueryService $queryService,
private readonly StudioBulkActionService $bulkService,
) {}
/**
* GET /api/studio/artworks
* List artworks with search, filter, sort, pagination.
*/
public function index(Request $request): JsonResponse
{
$userId = $request->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);
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\Studio\StudioMetricsService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* Serves Studio Inertia pages for authenticated creators.
*/
final class StudioController extends Controller
{
public function __construct(
private readonly StudioMetricsService $metrics,
) {}
/**
* Studio Overview Dashboard (/studio)
*/
public function index(Request $request): Response
{
$userId = $request->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();
}
}

View File

@@ -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) [

View File

@@ -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';
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
/**
* Build item-item co-occurrence pairs from user favourites.
*
* Spec §7.1 runs hourly or every few hours.
* For each user: take last N favourites, create pairs, increment weights.
*
* Safety: limits per-user pairs to avoid O() explosion.
*/
final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 600;
public function __construct(
private readonly int $userBatchSize = 500,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->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<int, int> artwork_id => total favourite count */
private array $artworkLikeCounts = [];
/**
* Collect pairs from a single user's last N favourites.
*
* @return list<array{0: int, 1: int}>
*/
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<string, float> $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'],
);
}
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
/**
* Compute behavior-based (co-like) similarity from precomputed item pairs.
*
* Spec §7.3 runs nightly.
* For each artwork: read top pairs from rec_item_pairs, store top N.
*/
final class RecComputeSimilarByBehaviorJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 600;
public function __construct(
private readonly ?int $artworkId = null,
private readonly int $batchSize = 200,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->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(),
],
);
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
/**
* Compute tag-based (+ category boost) similarity for artworks.
*
* Spec §7.2 runs nightly + on-demand.
* For each artwork: find candidates by shared tags/category, score with IDF-weighted
* tag overlap, apply diversity, store top N.
*/
final class RecComputeSimilarByTagsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 600;
public function __construct(
private readonly ?int $artworkId = null,
private readonly int $batchSize = 200,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->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(),
],
);
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Compute hybrid similarity by blending tag, behavior, and optionally visual scores.
*
* Spec §7.4 runs nightly.
* Merges candidates from tag + behavior + vector lists, applies hybrid blend weights,
* enforces diversity, stores top 30.
*/
final class RecComputeSimilarHybridJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 900;
public function __construct(
private readonly ?int $artworkId = null,
private readonly int $batchSize = 200,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->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<int> $ids
* @return array<int, float>
*/
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;
}
}

View File

@@ -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,

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkMetricSnapshotHourly
*
* Stores hourly totals for artwork metrics. Deltas are computed by
* subtracting the previous hour's snapshot from the current one.
*
* @property int $id
* @property int $artwork_id
* @property \Illuminate\Support\Carbon $bucket_hour
* @property int $views_count
* @property int $downloads_count
* @property int $favourites_count
* @property int $comments_count
* @property int $shares_count
* @property \Illuminate\Support\Carbon $created_at
*/
class ArtworkMetricSnapshotHourly extends Model
{
protected $table = 'artwork_metric_snapshots_hourly';
public $timestamps = false;
protected $fillable = [
'artwork_id',
'bucket_hour',
'views_count',
'downloads_count',
'favourites_count',
'comments_count',
'shares_count',
];
protected $casts = [
'bucket_hour' => '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');
}
}

View File

@@ -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

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Precomputed recommendation list for an artwork.
*
* @property int $id
* @property int $artwork_id
* @property string $rec_type similar_hybrid|similar_visual|similar_tags|similar_behavior
* @property array $recs Ordered array of artwork IDs
* @property string $model_version e.g. "sim_v1"
* @property \Carbon\Carbon $computed_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*
* @property-read Artwork $artwork
*/
final class RecArtworkRec extends Model
{
protected $table = 'rec_artwork_recs';
protected $fillable = [
'artwork_id',
'rec_type',
'recs',
'model_version',
'computed_at',
];
protected $casts = [
'artwork_id' => 'integer',
'recs' => 'array',
'computed_at' => 'datetime',
];
// ── Relations ──────────────────────────────────────────────────────────
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

54
app/Models/RecEvent.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Recommendation event log entry.
*
* @property int $id
* @property int|null $user_id
* @property string|null $session_id
* @property string $event_type view|favourite|download
* @property int $artwork_id
* @property \Carbon\Carbon $created_at
*
* @property-read User|null $user
* @property-read Artwork $artwork
*/
final class RecEvent extends Model
{
protected $table = 'rec_events';
public $timestamps = false;
protected $fillable = [
'user_id',
'session_id',
'event_type',
'artwork_id',
'created_at',
];
protected $casts = [
'user_id' => '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);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Item-item co-occurrence pair for behavior-based similarity.
*
* @property int $a_artwork_id
* @property int $b_artwork_id
* @property float $weight
* @property \Carbon\Carbon $updated_at
*
* @property-read Artwork $artworkA
* @property-read Artwork $artworkB
*/
final class RecItemPair extends Model
{
protected $table = 'rec_item_pairs';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'a_artwork_id',
'b_artwork_id',
'weight',
'updated_at',
];
protected $casts = [
'a_artwork_id' => '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');
}
}

View File

@@ -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());
}
}
}

View File

@@ -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. */

View File

@@ -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(),
);
}
/**

View File

@@ -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.
*/

View File

@@ -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`.
*

View File

@@ -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;
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Runtime service for the Similar Artworks hybrid recommender (spec §8).
*
* Flow:
* 1. Try precomputed similar_hybrid list
* 2. Else similar_visual (if enabled)
* 3. Else similar_tags
* 4. Else similar_behavior
* 5. Else trending fallback in the same category/content_type
*
* Lists are cached in Redis/cache with a configurable TTL.
* Hydration fetches artworks in one query, preserving stored order.
* An author-cap diversity filter is applied at runtime as a final check.
*/
final class HybridSimilarArtworksService
{
private const FALLBACK_ORDER = [
'similar_hybrid',
'similar_visual',
'similar_tags',
'similar_behavior',
];
/**
* Get similar artworks for the given artwork.
*
* @param string|null $type null|'similar'='hybrid fallback', 'visual', 'tags', 'behavior'
* @return Collection<int, Artwork>
*/
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<int>
*/
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<int>
*/
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();
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* pgvector adapter uses the artwork_embeddings table with cosine similarity.
*
* Requires PostgreSQL with the pgvector extension installed.
* Schema: artwork_embeddings (artwork_id PK, model, dims, embedding vector(N), ...)
*
* Spec §9 Option A.
*/
final class PgvectorAdapter implements VectorAdapterInterface
{
public function querySimilar(int $artworkId, int $topK = 100): array
{
// Fetch reference embedding
$ref = DB::table('artwork_embeddings')
->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();
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Managed vector DB adapter (Pinecone-style REST API).
*
* Spec §9 Option B.
*
* Configuration:
* recommendations.similarity.pinecone.api_key
* recommendations.similarity.pinecone.index_host
* recommendations.similarity.pinecone.index_name
* recommendations.similarity.pinecone.namespace
* recommendations.similarity.pinecone.top_k
*/
final class PineconeAdapter implements VectorAdapterInterface
{
private function apiKey(): string
{
return (string) config('recommendations.similarity.pinecone.api_key', '');
}
private function host(): string
{
return rtrim((string) config('recommendations.similarity.pinecone.index_host', ''), '/');
}
private function namespace(): string
{
return (string) config('recommendations.similarity.pinecone.namespace', '');
}
public function querySimilar(int $artworkId, int $topK = 100): array
{
$configTopK = (int) config('recommendations.similarity.pinecone.top_k', 100);
$effectiveTopK = min($topK, $configTopK);
$vectorId = "artwork:{$artworkId}";
try {
$response = Http::withHeaders([
'Api-Key' => $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()}");
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
use Illuminate\Support\Facades\Log;
/**
* Factory to resolve the configured VectorAdapterInterface implementation.
*/
final class VectorAdapterFactory
{
/**
* @return VectorAdapterInterface|null null when vector similarity is disabled
*/
public static function make(): ?VectorAdapterInterface
{
if (! (bool) config('recommendations.similarity.vector_enabled', false)) {
return null;
}
$adapter = (string) config('recommendations.similarity.vector_adapter', 'pgvector');
return match ($adapter) {
'pgvector' => 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();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
/**
* Contract for vector-similarity adapters (pgvector, Pinecone, etc.).
*
* Each adapter can query nearest-neighbor artworks for a given artwork ID
* and return an ordered list of (artwork_id, score) pairs.
*/
interface VectorAdapterInterface
{
/**
* Find the most visually similar artworks.
*
* @param int $artworkId Source artwork
* @param int $topK Max neighbors to return
* @return list<array{artwork_id: int, score: float}> 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;
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
/**
* Handles artwork listing queries for Studio, using Meilisearch with DB fallback.
*/
final class StudioArtworkQueryService
{
/**
* List artworks for a creator with search, filter, and sort via Meilisearch.
*
* Supported $filters keys:
* q string free-text search
* status string published|draft|archived
* category string category slug
* tags array tag slugs
* date_from string Y-m-d
* date_to string Y-m-d
* performance string rising|top|low
* sort string created_at:desc (default), ranking_score:desc, heat_score:desc, etc.
*/
public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator
{
// Skip Meilisearch when driver is null (e.g. in tests)
$driver = config('scout.driver');
if (empty($driver) || $driver === 'null') {
return $this->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);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\Tag;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Handles bulk operations on artworks for the Studio module.
*/
final class StudioBulkActionService
{
/**
* Execute a bulk action on the given artwork IDs, enforcing ownership.
*
* @param int $userId The authenticated user ID
* @param string $action publish|unpublish|archive|unarchive|delete|change_category|add_tags|remove_tags
* @param array $artworkIds Array of artwork IDs
* @param array $params Extra params (category_id, tag_ids)
* @return array{success: int, failed: int, errors: array}
*/
public function execute(int $userId, string $action, array $artworkIds, array $params = []): array
{
$result = ['success' => 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(),
]);
}
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Provides dashboard KPI data for the Studio overview page.
*/
final class StudioMetricsService
{
private const CACHE_TTL = 300; // 5 minutes
/**
* Get dashboard KPI metrics for a creator.
*
* @return array{total_artworks: int, views_30d: int, favourites_30d: int, shares_30d: int, followers: int}
*/
public function getDashboardKpis(int $userId): array
{
$cacheKey = "studio.kpi.{$userId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
$totalArtworks = Artwork::where('user_id', $userId)
->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,
];
});
}
}

View File

@@ -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());
}
}
}

View File

@@ -2,4 +2,5 @@
return [
'uploads_v2' => (bool) env('SKINBASE_UPLOADS_V2', true),
'similarity_vector' => (bool) env('SIMILARITY_VECTOR_ENABLED', false),
];

View File

@@ -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),
],
],
];

View File

@@ -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',

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('artwork_metric_snapshots_hourly', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artwork_stats', function (Blueprint $table) {
$table->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',
]);
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('rec_artwork_recs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('rec_item_pairs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('rec_events', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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 */}
<HomeHero artwork={hero} isLoggedIn={false} />
<Suspense fallback={<SectionFallback />}>
<HomeRising items={rising} />
</Suspense>
<Suspense fallback={<SectionFallback />}>
<HomeTrending items={trending} />
</Suspense>
@@ -73,6 +77,7 @@ function AuthHomePage(props) {
user_data,
hero,
from_following,
rising,
trending,
fresh,
by_tags,
@@ -104,6 +109,11 @@ function AuthHomePage(props) {
<HomeTrendingForYou items={by_tags} preferences={preferences} />
</Suspense>
{/* Rising Now */}
<Suspense fallback={<SectionFallback />}>
<HomeRising items={rising} />
</Suspense>
{/* 2. Global Trending Now */}
<Suspense fallback={<SectionFallback />}>
<HomeTrending items={trending} />

View File

@@ -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 (
<article className="min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0">
<a
href={item.url}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
<div className="relative aspect-video overflow-hidden bg-neutral-900">
{/* Gloss sheen */}
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
<img
src={item.thumb || FALLBACK}
alt={item.title}
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>
{/* Rising badge */}
<div className="absolute left-3 top-3 z-30">
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/80 px-2 py-1 text-[11px] font-bold text-white ring-1 ring-white/10 backdrop-blur-sm">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
Rising
</span>
</div>
{/* Top-right View badge */}
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
</div>
{/* Bottom info overlay */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
<img
src={item.author_avatar || AVATAR_FALLBACK}
alt={item.author}
className="w-6 h-6 rounded-full object-cover shrink-0"
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
/>
<span className="truncate">{item.author}</span>
{username && <span className="text-white/50 shrink-0">{username}</span>}
</div>
</div>
</div>
<span className="sr-only">{item.title} by {item.author}</span>
</a>
</article>
)
}
export default function HomeRising({ items }) {
if (!Array.isArray(items) || items.length === 0) return null
return (
<section className="mt-14 px-4 sm:px-6 lg:px-8">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="text-emerald-400">🚀</span> Rising Now
</h2>
<a href="/discover/rising" className="text-sm text-nova-300 hover:text-white transition">
See all
</a>
</div>
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-5 lg:overflow-visible">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<ArtCard key={item.id} item={item} />
))}
</div>
</section>
)
}

View File

@@ -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 (
<StudioLayout title="Analytics">
{/* KPI Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{kpiItems.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 transition-all">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl ${item.bg} flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon}`} />
</div>
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider leading-tight">{item.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{(totals?.[item.key] ?? 0).toLocaleString()}
</p>
</div>
))}
</div>
{/* Performance Averages */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
{performanceItems.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 transition-all">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl ${item.bg} flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon} text-lg`} />
</div>
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{item.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{(totals?.[item.key] ?? 0).toFixed(1)}
</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Content Breakdown */}
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-chart-pie text-slate-500 mr-2" />
Content Breakdown
</h3>
{contentBreakdown?.length > 0 ? (
<div className="space-y-3">
{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 (
<div key={ct.slug} className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg ${bgColor} flex items-center justify-center ${textColor} flex-shrink-0`}>
<i className={`fa-solid ${iconClass} text-xs`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-white">{ct.name}</span>
<span className="text-xs text-slate-400 tabular-nums">{ct.count}</span>
</div>
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div
className={`h-full rounded-full ${bgColor.replace('/10', '/40')}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
)
})}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-6">No artworks categorised yet</p>
)}
</div>
{/* Recent Comments */}
<div className="lg:col-span-2 bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-comments text-slate-500 mr-2" />
Recent Comments
</h3>
{recentComments?.length > 0 ? (
<div className="space-y-0 divide-y divide-white/5">
{recentComments.map((c) => (
<div key={c.id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
<div className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-xs text-slate-500 flex-shrink-0">
<i className="fa-solid fa-user" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<span className="font-medium text-accent">{c.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{c.artwork_title}</span>
</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{c.body}</p>
<p className="text-[10px] text-slate-600 mt-1">{new Date(c.created_at).toLocaleDateString()}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-6">No comments yet</p>
)}
</div>
</div>
{/* Top Performers Table */}
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-sm font-semibold text-white mb-4">
<i className="fa-solid fa-ranking-star text-slate-500 mr-2" />
Top 10 Artworks
</h3>
{topArtworks?.length > 0 ? (
<div className="overflow-x-auto sb-scrollbar">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wider text-slate-500 border-b border-white/5">
<th className="pb-3 pr-4">#</th>
<th className="pb-3 pr-4">Artwork</th>
<th className="pb-3 pr-4 text-right">Views</th>
<th className="pb-3 pr-4 text-right">Favs</th>
<th className="pb-3 pr-4 text-right">Shares</th>
<th className="pb-3 pr-4 text-right">Downloads</th>
<th className="pb-3 pr-4 text-right">Ranking</th>
<th className="pb-3 text-right">Heat</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{topArtworks.map((art, i) => (
<tr key={art.id} className="hover:bg-white/[0.02] transition-colors">
<td className="py-3 pr-4 text-slate-500 tabular-nums">{i + 1}</td>
<td className="py-3 pr-4">
<Link
href={`/studio/artworks/${art.id}/analytics`}
className="flex items-center gap-3 group"
>
{art.thumb_url && (
<img
src={art.thumb_url}
alt={art.title}
className="w-9 h-9 rounded-lg object-cover bg-nova-800 flex-shrink-0 group-hover:ring-2 ring-accent/50 transition-all"
/>
)}
<span className="text-white font-medium truncate max-w-[200px] group-hover:text-accent transition-colors">
{art.title}
</span>
</Link>
</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.views.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.favourites.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.shares.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-slate-300 tabular-nums">{art.downloads.toLocaleString()}</td>
<td className="py-3 pr-4 text-right text-yellow-400 tabular-nums font-medium">{art.ranking_score.toFixed(1)}</td>
<td className="py-3 text-right tabular-nums">
<span className={`font-medium ${art.heat_score > 5 ? 'text-orange-400' : 'text-slate-400'}`}>
{art.heat_score.toFixed(1)}
</span>
{art.heat_score > 5 && (
<i className="fa-solid fa-fire text-orange-400 ml-1 text-[10px]" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-slate-500 text-center py-8">No published artworks with stats yet</p>
)}
</div>
</StudioLayout>
)
}

View File

@@ -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 (
<StudioLayout title="Archived">
<StudioToolbar
search={search}
onSearchChange={setSearch}
sort={sort}
onSortChange={setSort}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onFilterToggle={() => {}}
selectedCount={selectedIds.length}
/>
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!loading && viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artworks.map((art) => (
<StudioGridCard key={art.id} artwork={art} selected={selectedIds.includes(art.id)} onSelect={toggleSelect} onAction={handleAction} />
))}
</div>
)}
{!loading && viewMode === 'list' && (
<StudioTable artworks={artworks} selectedIds={selectedIds} onSelect={toggleSelect} onSelectAll={selectAll} onAction={handleAction} onSort={setSort} currentSort={sort} />
)}
{!loading && artworks.length === 0 && (
<div className="text-center py-16">
<i className="fa-solid fa-box-archive text-4xl text-slate-600 mb-4" />
<p className="text-slate-500 text-sm">No archived artworks</p>
</div>
)}
{meta.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
{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) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && <span className="text-slate-600 text-sm"></span>}
<button onClick={() => fetchArtworks(page)} className={`w-9 h-9 rounded-xl text-sm font-medium transition-all ${page === meta.current_page ? 'bg-accent text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}>{page}</button>
</React.Fragment>
))}
</div>
)}
<BulkActionsBar count={selectedIds.length} onExecute={executeBulk} onClearSelection={() => setSelectedIds([])} />
<ConfirmDangerModal open={deleteModal.open} onClose={() => setDeleteModal({ open: false, ids: [] })} onConfirm={confirmDelete} title="Permanently delete?" message={`Delete ${deleteModal.ids.length} artwork(s) permanently?`} />
<BulkTagModal open={tagModal.open} mode={tagModal.mode} onClose={() => setTagModal({ open: false, mode: 'add' })} onConfirm={confirmBulkTags} />
<BulkCategoryModal open={categoryModal.open} categories={categories} onClose={() => setCategoryModal({ open: false })} onConfirm={confirmBulkCategory} />
</StudioLayout>
)
}

View File

@@ -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 (
<StudioLayout title={`Analytics: ${artwork?.title || 'Artwork'}`}>
{/* Back link */}
<Link
href="/studio/artworks"
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
>
<i className="fa-solid fa-arrow-left" />
Back to Artworks
</Link>
{/* Artwork header */}
<div className="flex items-center gap-4 mb-8 bg-nova-900/60 border border-white/10 rounded-2xl p-4">
{artwork?.thumb_url && (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-20 h-20 rounded-xl object-cover bg-nova-800"
/>
)}
<div>
<h2 className="text-lg font-bold text-white">{artwork?.title}</h2>
<p className="text-xs text-slate-500 mt-1">/{artwork?.slug}</p>
</div>
</div>
{/* KPI row */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{kpiItems.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-2">
<i className={`fa-solid ${item.icon} ${item.color}`} />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{item.label}</span>
</div>
<p className="text-2xl font-bold text-white tabular-nums">
{(analytics?.[item.key] ?? 0).toLocaleString()}
</p>
</div>
))}
</div>
{/* Performance metrics */}
<h3 className="text-base font-bold text-white mb-4">Performance Metrics</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{metricCards.map((item) => (
<div key={item.key} className="bg-nova-900/60 border border-white/10 rounded-2xl p-5">
<div className="flex items-center gap-2 mb-3">
<div className={`w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center ${item.color}`}>
<i className={`fa-solid ${item.icon} text-lg`} />
</div>
<span className="text-sm font-medium text-slate-300">{item.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{(analytics?.[item.key] ?? 0).toFixed(1)}
</p>
</div>
))}
</div>
{/* Placeholder sections for future features */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6">
<h4 className="text-sm font-semibold text-white mb-3">
<i className="fa-solid fa-chart-line mr-2 text-slate-500" />
Traffic Sources
</h4>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<i className="fa-solid fa-chart-pie text-3xl text-slate-700 mb-3" />
<p className="text-xs text-slate-500">Coming soon</p>
<p className="text-[10px] text-slate-600 mt-1">Traffic source tracking is on the roadmap</p>
</div>
</div>
</div>
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6">
<h4 className="text-sm font-semibold text-white mb-3">
<i className="fa-solid fa-share-from-square mr-2 text-slate-500" />
Shares by Platform
</h4>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<i className="fa-solid fa-share-nodes text-3xl text-slate-700 mb-3" />
<p className="text-xs text-slate-500">Coming soon</p>
<p className="text-[10px] text-slate-600 mt-1">Platform-level share tracking coming in v2</p>
</div>
</div>
</div>
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-6 lg:col-span-2">
<h4 className="text-sm font-semibold text-white mb-3">
<i className="fa-solid fa-trophy mr-2 text-slate-500" />
Ranking History
</h4>
<div className="flex items-center justify-center py-8">
<div className="text-center">
<i className="fa-solid fa-chart-area text-3xl text-slate-700 mb-3" />
<p className="text-xs text-slate-500">Coming soon</p>
<p className="text-[10px] text-slate-600 mt-1">Historical ranking data will be tracked in a future update</p>
</div>
</div>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -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 (
<StudioLayout title="Edit Artwork">
<Link
href="/studio/artworks"
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
>
<i className="fa-solid fa-arrow-left" />
Back to Artworks
</Link>
<div className="max-w-3xl space-y-8">
{/* ── Uploaded Asset ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Uploaded Asset</h3>
<div className="flex items-start gap-5">
{thumbUrl ? (
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
) : (
<div className="w-32 h-32 rounded-xl bg-nova-800 flex items-center justify-center text-slate-600 flex-shrink-0">
<i className="fa-solid fa-image text-2xl" />
</div>
)}
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm text-white font-medium truncate">{fileMeta.name}</p>
<p className="text-xs text-slate-400">{formatBytes(fileMeta.size)}</p>
{fileMeta.width > 0 && (
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={replacing}
className="mt-2 inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
>
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
{replacing ? 'Replacing…' : 'Replace file'}
</button>
</div>
</div>
</section>
{/* ── Content Type ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Content Type</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{contentTypes.map((ct) => {
const active = ct.id === contentTypeId
const vk = getContentTypeVisualKey(ct.slug)
return (
<button
key={ct.id}
type="button"
onClick={() => handleContentTypeChange(ct.id)}
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all cursor-pointer
${active ? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-400/10' : 'border-white/10 bg-white/5 hover:border-white/20'}`}
>
<img src={`/gfx/mascot_${vk}.webp`} alt={ct.name} className="w-14 h-14 object-contain" />
<span className={`text-xs font-semibold ${active ? 'text-emerald-300' : 'text-slate-300'}`}>{ct.name}</span>
{active && (
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center">
<i className="fa-solid fa-check text-[10px] text-white" />
</span>
)}
</button>
)
})}
</div>
</section>
{/* ── Category ── */}
{rootCategories.length > 0 && (
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Category</h3>
<div className="flex flex-wrap gap-2">
{rootCategories.map((cat) => {
const active = cat.id === categoryId
return (
<button
key={cat.id}
type="button"
onClick={() => handleCategoryChange(cat.id)}
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
${active ? 'border-purple-600/90 bg-purple-700/35 text-purple-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
>
{cat.name}
</button>
)
})}
</div>
</div>
{/* Subcategory */}
{subCategories.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Subcategory</h3>
<div className="flex flex-wrap gap-2">
{subCategories.map((sub) => {
const active = sub.id === subCategoryId
return (
<button
key={sub.id}
type="button"
onClick={() => setSubCategoryId(active ? null : sub.id)}
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
${active ? 'border-cyan-600/90 bg-cyan-700/35 text-cyan-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
>
{sub.name}
</button>
)
})}
</div>
</div>
)}
</section>
)}
{/* ── Basics ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">Basics</h3>
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Title</label>
<input
type="text"
value={title}
onChange={(e) => 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 && <p className="text-xs text-red-400 mt-1">{errors.title[0]}</p>}
</div>
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
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 resize-y"
/>
{errors.description && <p className="text-xs text-red-400 mt-1">{errors.description[0]}</p>}
</div>
</section>
{/* ── Tags ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Tags</h3>
{/* Search input */}
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
ref={tagInputRef}
type="text"
value={tagQuery}
onChange={(e) => setTagQuery(e.target.value)}
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
style={{ paddingLeft: '2.5rem' }}
placeholder="Search tags…"
/>
</div>
{/* Selected tag chips */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-accent/20 text-accent"
>
{tag.name}
<button
onClick={() => removeTag(tag.id)}
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
>
<i className="fa-solid fa-xmark text-[10px]" />
</button>
</span>
))}
</div>
)}
{/* Results list */}
<div className="max-h-48 overflow-y-auto sb-scrollbar space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
{tagLoading && (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!tagLoading && tagResults.length === 0 && (
<p className="text-center text-sm text-slate-500 py-4">
{tagQuery ? 'No tags found' : 'Type to search tags'}
</p>
)}
{!tagLoading &&
tagResults.map((tag) => {
const isSelected = tags.some((t) => t.id === tag.id)
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
isSelected
? 'bg-accent/10 text-accent'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
<span className="flex items-center gap-2">
<i
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
isSelected ? 'text-accent' : 'text-slate-500'
}`}
/>
{tag.name}
</span>
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
</button>
)
})}
</div>
<p className="text-xs text-slate-500">{tags.length}/15 tags selected</p>
{errors.tags && <p className="text-xs text-red-400">{errors.tags[0]}</p>}
</section>
{/* ── Visibility ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Visibility</h3>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={isPublic} onChange={() => setIsPublic(true)} className="text-accent focus:ring-accent/50" />
<span className="text-sm text-white">Published</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={!isPublic} onChange={() => setIsPublic(false)} className="text-accent focus:ring-accent/50" />
<span className="text-sm text-white">Draft</span>
</label>
</div>
</section>
{/* ── Actions ── */}
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save changes'}
</button>
{saved && (
<span className="text-sm text-emerald-400 flex items-center gap-1">
<i className="fa-solid fa-check" /> Saved
</span>
)}
<Link
href={`/studio/artworks/${artwork?.id}/analytics`}
className="ml-auto px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
>
<i className="fa-solid fa-chart-line mr-2" />
Analytics
</Link>
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,341 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import StudioToolbar from '../../Components/Studio/StudioToolbar'
import StudioFilters from '../../Components/Studio/StudioFilters'
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'
const VIEW_MODE_KEY = 'studio_view_mode'
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
export default function StudioArtworks() {
const { props } = usePage()
const { categories } = props
// State
const [viewMode, setViewMode] = useState(() => localStorage.getItem(VIEW_MODE_KEY) || 'grid')
const [artworks, setArtworks] = useState([])
const [meta, setMeta] = useState({ current_page: 1, last_page: 1, per_page: 24, total: 0 })
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [sort, setSort] = useState('created_at:desc')
const [filtersOpen, setFiltersOpen] = useState(false)
const [filters, setFilters] = useState({ status: '', category: '', performance: '', date_from: '', date_to: '', tags: [] })
const [selectedIds, setSelectedIds] = useState([])
const [deleteModal, setDeleteModal] = useState({ open: false, ids: [] })
const [tagModal, setTagModal] = useState({ open: false, mode: 'add' })
const [categoryModal, setCategoryModal] = useState({ open: false })
const searchTimer = useRef(null)
const perPage = viewMode === 'list' ? 50 : 24
// Fetch artworks from API
const fetchArtworks = useCallback(async (page = 1) => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('page', page)
params.set('per_page', perPage)
params.set('sort', sort)
if (search) params.set('q', search)
if (filters.status) params.set('status', filters.status)
if (filters.category) params.set('category', filters.category)
if (filters.performance) params.set('performance', filters.performance)
if (filters.date_from) params.set('date_from', filters.date_from)
if (filters.date_to) params.set('date_to', filters.date_to)
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 artworks:', err)
} finally {
setLoading(false)
}
}, [search, sort, filters, perPage])
// Debounced search
useEffect(() => {
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => fetchArtworks(1), 300)
return () => clearTimeout(searchTimer.current)
}, [fetchArtworks])
// Persist view mode
const handleViewModeChange = (mode) => {
setViewMode(mode)
localStorage.setItem(VIEW_MODE_KEY, mode)
}
// Selection
const toggleSelect = (id) => {
setSelectedIds((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id])
}
const selectAll = () => {
const allIds = artworks.map((a) => a.id)
const allSelected = allIds.every((id) => selectedIds.includes(id))
setSelectedIds(allSelected ? [] : allIds)
}
const clearSelection = () => setSelectedIds([])
// Actions
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
}
// Toggle actions
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('Action failed:', err)
}
}
// Bulk action execution
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: {} }),
})
clearSelection()
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Bulk action failed:', err)
}
}
// Confirm bulk tag action
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 } }),
})
clearSelection()
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Bulk tag action failed:', err)
}
}
// Confirm bulk category change
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 } }),
})
clearSelection()
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Bulk category action failed:', err)
}
}
// Confirm delete
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((prev) => prev.filter((id) => !deleteModal.ids.includes(id)))
fetchArtworks(meta.current_page)
} catch (err) {
console.error('Delete failed:', err)
}
}
return (
<StudioLayout title="Artworks">
{/* Toolbar */}
<StudioToolbar
search={search}
onSearchChange={setSearch}
sort={sort}
onSortChange={setSort}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onFilterToggle={() => setFiltersOpen(!filtersOpen)}
selectedCount={selectedIds.length}
/>
<div className="flex gap-4">
{/* Filters sidebar (desktop) */}
<div className="hidden lg:block">
<StudioFilters
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
filters={filters}
onFilterChange={setFilters}
categories={categories}
/>
</div>
{/* Mobile filter drawer */}
<div className="lg:hidden">
<StudioFilters
open={filtersOpen}
onClose={() => setFiltersOpen(false)}
filters={filters}
onFilterChange={setFilters}
categories={categories}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{/* Grid view */}
{!loading && viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artworks.map((art) => (
<StudioGridCard
key={art.id}
artwork={art}
selected={selectedIds.includes(art.id)}
onSelect={toggleSelect}
onAction={handleAction}
/>
))}
</div>
)}
{/* List view */}
{!loading && viewMode === 'list' && (
<StudioTable
artworks={artworks}
selectedIds={selectedIds}
onSelect={toggleSelect}
onSelectAll={selectAll}
onAction={handleAction}
onSort={setSort}
currentSort={sort}
/>
)}
{/* Empty state */}
{!loading && artworks.length === 0 && (
<div className="text-center py-16">
<i className="fa-solid fa-images text-4xl text-slate-600 mb-4" />
<p className="text-slate-500 text-sm">No artworks match your criteria</p>
</div>
)}
{/* Pagination */}
{meta.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
{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) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && (
<span className="text-slate-600 text-sm"></span>
)}
<button
onClick={() => fetchArtworks(page)}
className={`w-9 h-9 rounded-xl text-sm font-medium transition-all ${
page === meta.current_page
? 'bg-accent text-white'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{page}
</button>
</React.Fragment>
))}
</div>
)}
{/* Total count */}
{!loading && meta.total > 0 && (
<p className="text-center text-xs text-slate-600 mt-3">
{meta.total.toLocaleString()} artwork{meta.total !== 1 ? 's' : ''} total
</p>
)}
</div>
</div>
{/* Bulk actions bar */}
<BulkActionsBar
count={selectedIds.length}
onExecute={executeBulk}
onClearSelection={clearSelection}
/>
{/* Delete confirmation modal */}
<ConfirmDangerModal
open={deleteModal.open}
onClose={() => setDeleteModal({ open: false, ids: [] })}
onConfirm={confirmDelete}
title="Permanently delete artworks?"
message={`This will permanently delete ${deleteModal.ids.length} artwork${deleteModal.ids.length !== 1 ? 's' : ''}. This action cannot be undone.`}
/>
{/* Bulk tag modal */}
<BulkTagModal
open={tagModal.open}
mode={tagModal.mode}
onClose={() => setTagModal({ open: false, mode: 'add' })}
onConfirm={confirmBulkTags}
/>
{/* Bulk category modal */}
<BulkCategoryModal
open={categoryModal.open}
categories={categories}
onClose={() => setCategoryModal({ open: false })}
onConfirm={confirmBulkCategory}
/>
</StudioLayout>
)
}

View File

@@ -0,0 +1,141 @@
import React from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
const kpiConfig = [
{ key: 'total_artworks', label: 'Total Artworks', icon: 'fa-images', color: 'text-blue-400', link: '/studio/artworks' },
{ key: 'views_30d', label: 'Views (30d)', icon: 'fa-eye', color: 'text-emerald-400', link: null },
{ key: 'favourites_30d', label: 'Favourites (30d)', icon: 'fa-heart', color: 'text-pink-400', link: null },
{ key: 'shares_30d', label: 'Shares (30d)', icon: 'fa-share-nodes', color: 'text-amber-400', link: null },
{ key: 'followers', label: 'Followers', icon: 'fa-user-group', color: 'text-purple-400', link: null },
]
function KpiCard({ config, value }) {
const content = (
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-5 hover:border-white/20 hover:shadow-lg hover:shadow-accent/5 transition-all duration-300 cursor-pointer group">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center ${config.color} group-hover:scale-110 transition-transform`}>
<i className={`fa-solid ${config.icon}`} />
</div>
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">{config.label}</span>
</div>
<p className="text-3xl font-bold text-white tabular-nums">
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
</div>
)
if (config.link) {
return <Link href={config.link}>{content}</Link>
}
return content
}
function TopPerformerCard({ artwork }) {
return (
<div className="bg-nova-900/60 border border-white/10 rounded-2xl p-4 hover:border-white/20 hover:shadow-lg hover:shadow-accent/5 transition-all duration-300 group">
<div className="flex items-start gap-3">
{artwork.thumb_url && (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-16 h-16 rounded-xl object-cover bg-nova-800 flex-shrink-0 group-hover:scale-105 transition-transform"
loading="lazy"
/>
)}
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold text-white truncate" title={artwork.title}>
{artwork.title}
</h4>
<div className="flex flex-wrap items-center gap-3 mt-1.5">
<span className="text-xs text-slate-400">
{artwork.favourites?.toLocaleString()}
</span>
<span className="text-xs text-slate-400">
🔗 {artwork.shares?.toLocaleString()}
</span>
</div>
{artwork.heat_score > 5 && (
<span className="inline-flex items-center gap-1 mt-2 px-2 py-0.5 rounded-md text-[10px] font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30">
<i className="fa-solid fa-fire" /> Rising
</span>
)}
</div>
</div>
</div>
)
}
function RecentComment({ comment }) {
return (
<div className="flex items-start gap-3 py-3 border-b border-white/5 last:border-0">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs text-slate-400 flex-shrink-0">
<i className="fa-solid fa-comment" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-white">
<span className="font-medium text-accent">{comment.author_name}</span>
{' '}on{' '}
<span className="text-slate-300">{comment.artwork_title}</span>
</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{comment.body}</p>
<p className="text-[10px] text-slate-600 mt-1">
{new Date(comment.created_at).toLocaleDateString()}
</p>
</div>
</div>
)
}
export default function StudioDashboard() {
const { props } = usePage()
const { kpis, topPerformers, recentComments } = props
return (
<StudioLayout title="Studio Overview">
{/* KPI Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{kpiConfig.map((config) => (
<KpiCard key={config.key} config={config} value={kpis?.[config.key] ?? 0} />
))}
</div>
{/* Top Performers */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-white">Your Top Performers</h2>
<span className="text-xs text-slate-500">Last 7 days</span>
</div>
{topPerformers?.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{topPerformers.map((art) => (
<TopPerformerCard key={art.id} artwork={art} />
))}
</div>
) : (
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-8 text-center">
<p className="text-slate-500 text-sm">No artworks yet. Upload your first creation!</p>
<Link
href="/upload"
className="inline-flex items-center gap-2 mt-4 px-5 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white text-sm font-semibold transition-all shadow-lg shadow-accent/25"
>
<i className="fa-solid fa-cloud-arrow-up" /> Upload
</Link>
</div>
)}
</div>
{/* Recent Comments */}
<div>
<h2 className="text-lg font-bold text-white mb-4">Recent Comments</h2>
<div className="bg-nova-900/40 border border-white/10 rounded-2xl p-4">
{recentComments?.length > 0 ? (
recentComments.map((c) => <RecentComment key={c.id} comment={c} />)
) : (
<p className="text-slate-500 text-sm text-center py-4">No comments yet</p>
)}
</div>
</div>
</StudioLayout>
)
}

View File

@@ -0,0 +1,208 @@
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 StudioDrafts() {
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
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
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', 'draft')
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 (
<StudioLayout title="Drafts">
<StudioToolbar
search={search}
onSearchChange={setSearch}
sort={sort}
onSortChange={setSort}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onFilterToggle={() => {}}
selectedCount={selectedIds.length}
/>
{loading && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!loading && viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artworks.map((art) => (
<StudioGridCard key={art.id} artwork={art} selected={selectedIds.includes(art.id)} onSelect={toggleSelect} onAction={handleAction} />
))}
</div>
)}
{!loading && viewMode === 'list' && (
<StudioTable artworks={artworks} selectedIds={selectedIds} onSelect={toggleSelect} onSelectAll={selectAll} onAction={handleAction} onSort={setSort} currentSort={sort} />
)}
{!loading && artworks.length === 0 && (
<div className="text-center py-16">
<i className="fa-solid fa-file-pen text-4xl text-slate-600 mb-4" />
<p className="text-slate-500 text-sm">No draft artworks</p>
</div>
)}
{meta.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
{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) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && <span className="text-slate-600 text-sm"></span>}
<button onClick={() => fetchArtworks(page)} className={`w-9 h-9 rounded-xl text-sm font-medium transition-all ${page === meta.current_page ? 'bg-accent text-white' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}>{page}</button>
</React.Fragment>
))}
</div>
)}
<BulkActionsBar count={selectedIds.length} onExecute={executeBulk} onClearSelection={() => setSelectedIds([])} />
<ConfirmDangerModal open={deleteModal.open} onClose={() => setDeleteModal({ open: false, ids: [] })} onConfirm={confirmDelete} title="Permanently delete?" message={`Delete ${deleteModal.ids.length} artwork(s) permanently?`} />
<BulkTagModal open={tagModal.open} mode={tagModal.mode} onClose={() => setTagModal({ open: false, mode: 'add' })} onConfirm={confirmBulkTags} />
<BulkCategoryModal open={categoryModal.open} categories={categories} onClose={() => setCategoryModal({ open: false })} onConfirm={confirmBulkCategory} />
</StudioLayout>
)
}

View File

@@ -0,0 +1,27 @@
import React from 'react'
export default function RisingBadge({ heatScore, rankingScore }) {
if (!heatScore && !rankingScore) return null
const isRising = heatScore > 5
const isTrending = rankingScore > 50
if (!isRising && !isTrending) return null
return (
<span className="inline-flex items-center gap-1">
{isRising && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30">
<i className="fa-solid fa-fire text-[10px]" />
Rising
</span>
)}
{isTrending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30">
<i className="fa-solid fa-arrow-trend-up text-[10px]" />
Trending
</span>
)}
</span>
)
}

View File

@@ -0,0 +1,18 @@
import React from 'react'
const statusConfig = {
published: { label: 'Published', className: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
draft: { label: 'Draft', className: 'bg-amber-500/20 text-amber-400 border-amber-500/30' },
archived: { label: 'Archived', className: 'bg-slate-500/20 text-slate-400 border-slate-500/30' },
scheduled: { label: 'Scheduled', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
}
export default function StatusBadge({ status }) {
const config = statusConfig[status] || statusConfig.draft
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${config.className}`}>
{config.label}
</span>
)
}

View File

@@ -0,0 +1,77 @@
import React, { useState } from 'react'
const actions = [
{ value: 'publish', label: 'Publish', icon: 'fa-eye', danger: false },
{ value: 'unpublish', label: 'Unpublish (draft)', icon: 'fa-eye-slash', danger: false },
{ value: 'archive', label: 'Archive', icon: 'fa-box-archive', danger: false },
{ value: 'unarchive', label: 'Unarchive', icon: 'fa-rotate-left', danger: false },
{ value: 'delete', label: 'Delete', icon: 'fa-trash', danger: true },
{ value: 'change_category', label: 'Change category', icon: 'fa-folder', danger: false },
{ value: 'add_tags', label: 'Add tags', icon: 'fa-tag', danger: false },
{ value: 'remove_tags', label: 'Remove tags', icon: 'fa-tags', danger: false },
]
export default function BulkActionsBar({ count, onExecute, onClearSelection }) {
const [action, setAction] = useState('')
if (count === 0) return null
const handleExecute = () => {
if (!action) return
onExecute(action)
setAction('')
}
const selectedAction = actions.find((a) => a.value === action)
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-nova-900/95 backdrop-blur-xl border-t border-white/10 px-4 py-3 shadow-xl shadow-black/20">
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-accent/20 text-accent text-sm font-bold">
{count}
</span>
<span className="text-sm text-slate-300">
{count === 1 ? 'artwork' : 'artworks'} selected
</span>
</div>
<div className="flex items-center gap-2">
<select
value={action}
onChange={(e) => setAction(e.target.value)}
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 min-w-[180px]"
>
<option value="" className="bg-nova-900">Choose action</option>
{actions.map((a) => (
<option key={a.value} value={a.value} className="bg-nova-900">
{a.label}
</option>
))}
</select>
<button
onClick={handleExecute}
disabled={!action}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
action
? selectedAction?.danger
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-accent hover:bg-accent/90 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
Apply
</button>
<button
onClick={onClearSelection}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Clear
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
import React, { useState, useEffect } from 'react'
/**
* Modal for choosing a category in bulk.
*
* Props:
* - open: boolean
* - categories: array of content types with nested categories
* - onClose: () => void
* - onConfirm: (categoryId: number) => void
*/
export default function BulkCategoryModal({ open, categories = [], onClose, onConfirm }) {
const [selectedId, setSelectedId] = useState('')
useEffect(() => {
if (open) setSelectedId('')
}, [open])
const handleConfirm = () => {
if (!selectedId) return
onConfirm(Number(selectedId))
}
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Enter' && selectedId) handleConfirm()
}
if (!open) return null
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<i className="fa-solid fa-folder text-accent" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Change category</h3>
<p className="text-sm text-slate-400">Choose a category to assign to the selected artworks.</p>
</div>
</div>
{/* Category select */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
<option value="" className="bg-nova-900">Select a category</option>
{categories.map((ct) => (
<optgroup key={ct.id} label={ct.name}>
{ct.categories?.map((cat) => (
<React.Fragment key={cat.id}>
<option value={cat.id} className="bg-nova-900">{cat.name}</option>
{cat.children?.map((ch) => (
<option key={ch.id} value={ch.id} className="bg-nova-900">&nbsp;&nbsp;{ch.name}</option>
))}
</React.Fragment>
))}
</optgroup>
))}
</select>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selectedId}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
selectedId
? 'bg-accent hover:bg-accent/90 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
Apply category
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
/**
* Modal for picking tags to add/remove in bulk.
*
* Props:
* - open: boolean
* - mode: 'add' | 'remove'
* - onClose: () => void
* - onConfirm: (tagIds: number[]) => void
*/
export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [selected, setSelected] = useState([]) // { id, name }
const [loading, setLoading] = useState(false)
const inputRef = useRef(null)
const searchTimer = useRef(null)
// Focus input when modal opens
useEffect(() => {
if (open) {
setQuery('')
setResults([])
setSelected([])
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
// Debounced tag search
const searchTags = useCallback(async (q) => {
setLoading(true)
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
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': csrfToken },
credentials: 'same-origin',
})
const data = await res.json()
setResults(data || [])
} catch {
setResults([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => searchTags(query), 250)
return () => clearTimeout(searchTimer.current)
}, [query, open, searchTags])
const toggleTag = (tag) => {
setSelected((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 }]
})
}
const removeSelected = (id) => {
setSelected((prev) => prev.filter((t) => t.id !== id))
}
const handleConfirm = () => {
if (selected.length === 0) return
onConfirm(selected.map((t) => t.id))
}
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose()
}
if (!open) return null
const isAdd = mode === 'add'
const title = isAdd ? 'Add tags' : 'Remove tags'
const accentColor = isAdd ? 'accent' : 'amber-500'
const icon = isAdd ? 'fa-tag' : 'fa-tags'
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-lg bg-nova-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full ${isAdd ? 'bg-accent/20' : 'bg-amber-500/20'} flex items-center justify-center flex-shrink-0`}>
<i className={`fa-solid ${icon} ${isAdd ? 'text-accent' : 'text-amber-400'}`} />
</div>
<div>
<h3 className="text-lg font-bold text-white">{title}</h3>
<p className="text-sm text-slate-400">
{isAdd ? 'Search and select tags to add to the selected artworks.' : 'Search and select tags to remove from the selected artworks.'}
</p>
</div>
</div>
{/* Search input */}
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
style={{ paddingLeft: '2.5rem' }}
placeholder="Search tags…"
/>
</div>
{/* Selected tags chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map((tag) => (
<span
key={tag.id}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium ${
isAdd ? 'bg-accent/20 text-accent' : 'bg-amber-500/20 text-amber-300'
}`}
>
{tag.name}
<button
onClick={() => removeSelected(tag.id)}
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
>
<i className="fa-solid fa-xmark text-[10px]" />
</button>
</span>
))}
</div>
)}
{/* Results list */}
<div className="max-h-48 overflow-y-auto space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
{loading && (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!loading && results.length === 0 && (
<p className="text-center text-sm text-slate-500 py-4">
{query ? 'No tags found' : 'Type to search tags'}
</p>
)}
{!loading &&
results.map((tag) => {
const isSelected = selected.some((t) => t.id === tag.id)
return (
<button
key={tag.id}
onClick={() => toggleTag(tag)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
isSelected
? isAdd
? 'bg-accent/10 text-accent'
: 'bg-amber-500/10 text-amber-300'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
<span className="flex items-center gap-2">
<i
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
isSelected ? (isAdd ? 'text-accent' : 'text-amber-400') : 'text-slate-500'
}`}
/>
{tag.name}
</span>
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
</button>
)
})}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selected.length === 0}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
selected.length > 0
? isAdd
? 'bg-accent hover:bg-accent/90 text-white'
: 'bg-amber-600 hover:bg-amber-700 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
{isAdd ? 'Add' : 'Remove'} {selected.length > 0 ? `${selected.length} tag${selected.length !== 1 ? 's' : ''}` : 'tags'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import React, { useState, useRef, useEffect } from 'react'
export default function ConfirmDangerModal({ open, onClose, onConfirm, title, message, confirmText = 'DELETE' }) {
const [input, setInput] = useState('')
const inputRef = useRef(null)
useEffect(() => {
if (open) {
setInput('')
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
if (!open) return null
const canConfirm = input === confirmText
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Enter' && canConfirm) onConfirm()
}
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onKeyDown={handleKeyDown}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md bg-nova-900 border border-red-500/30 rounded-2xl shadow-2xl shadow-red-500/10 p-6 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<i className="fa-solid fa-triangle-exclamation text-red-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">{title}</h3>
<p className="text-sm text-slate-400 mt-1">{message}</p>
</div>
</div>
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">
Type <span className="text-red-400 font-mono">{confirmText}</span> to confirm
</label>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-red-500/50 font-mono"
placeholder={confirmText}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={!canConfirm}
className={`px-5 py-2 rounded-xl text-sm font-semibold transition-all ${
canConfirm
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-white/5 text-slate-500 cursor-not-allowed'
}`}
>
Delete permanently
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
import React from 'react'
const statusOptions = [
{ value: '', label: 'All statuses' },
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
{ value: 'archived', label: 'Archived' },
]
const performanceOptions = [
{ value: '', label: 'All performance' },
{ value: 'rising', label: 'Rising (hot)' },
{ value: 'top', label: 'Top performers' },
{ value: 'low', label: 'Low performers' },
]
export default function StudioFilters({
open,
onClose,
filters,
onFilterChange,
categories = [],
}) {
if (!open) return null
const handleChange = (key, value) => {
onFilterChange({ ...filters, [key]: value })
}
return (
<>
{/* Mobile backdrop */}
<div className="lg:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Filter panel */}
<div className="fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 lg:w-64 bg-nova-900 lg:bg-nova-900/40 border-r lg:border border-white/10 lg:rounded-2xl p-5 space-y-5 overflow-y-auto lg:static lg:mb-4">
<div className="flex items-center justify-between lg:hidden">
<h3 className="text-base font-semibold text-white">Filters</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white" aria-label="Close filters">
<i className="fa-solid fa-xmark text-lg" />
</button>
</div>
<h3 className="hidden lg:block text-sm font-semibold text-slate-400 uppercase tracking-wider">Filters</h3>
{/* Status */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Status</label>
<select
value={filters.status || ''}
onChange={(e) => handleChange('status', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{statusOptions.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
))}
</select>
</div>
{/* Category */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Category</label>
<select
value={filters.category || ''}
onChange={(e) => handleChange('category', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
<option value="" className="bg-nova-900">All categories</option>
{categories.map((ct) => (
<optgroup key={ct.id} label={ct.name}>
{ct.categories?.map((cat) => (
<React.Fragment key={cat.id}>
<option value={cat.slug} className="bg-nova-900">{cat.name}</option>
{cat.children?.map((ch) => (
<option key={ch.id} value={ch.slug} className="bg-nova-900">&nbsp;&nbsp;{ch.name}</option>
))}
</React.Fragment>
))}
</optgroup>
))}
</select>
</div>
{/* Performance */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Performance</label>
<select
value={filters.performance || ''}
onChange={(e) => handleChange('performance', e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{performanceOptions.map((o) => (
<option key={o.value} value={o.value} className="bg-nova-900">{o.label}</option>
))}
</select>
</div>
{/* Date range */}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Date range</label>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={filters.date_from || ''}
onChange={(e) => handleChange('date_from', e.target.value)}
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
<input
type="date"
value={filters.date_to || ''}
onChange={(e) => handleChange('date_to', e.target.value)}
className="px-2 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
</div>
{/* Clear */}
<button
onClick={() => onFilterChange({ status: '', category: '', performance: '', date_from: '', date_to: '', tags: [] })}
className="w-full text-center text-xs text-slate-500 hover:text-white transition-colors py-2"
>
Clear all filters
</button>
</div>
</>
)
}

View File

@@ -0,0 +1,101 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
function getStatus(art) {
if (art.deleted_at) return 'archived'
if (!art.is_public) return 'draft'
return 'published'
}
function statItem(icon, value) {
return (
<span className="flex items-center gap-1 text-xs text-slate-400">
<span>{icon}</span>
<span>{typeof value === 'number' ? value.toLocaleString() : value}</span>
</span>
)
}
export default function StudioGridCard({ artwork, selected, onSelect, onAction }) {
const status = getStatus(artwork)
return (
<div
className={`group relative bg-nova-900/60 border rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/5 ${
selected ? 'border-accent/60 ring-2 ring-accent/20' : 'border-white/10 hover:border-white/20'
}`}
>
{/* Selection checkbox */}
<label className="absolute top-3 left-3 z-10 cursor-pointer">
<input
type="checkbox"
checked={selected}
onChange={() => onSelect(artwork.id)}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
/>
</label>
{/* Thumbnail */}
<div className="relative aspect-[4/3] bg-nova-800 overflow-hidden">
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
{/* Hover actions */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="absolute bottom-3 right-3 flex gap-1.5">
<ActionBtn icon="fa-eye" title="View public" onClick={() => window.open(`/artworks/${artwork.slug}`, '_blank')} />
<ActionBtn icon="fa-pen" title="Edit" onClick={() => onAction('edit', artwork)} />
{status !== 'archived' ? (
<ActionBtn icon="fa-box-archive" title="Archive" onClick={() => onAction('archive', artwork)} />
) : (
<ActionBtn icon="fa-rotate-left" title="Unarchive" onClick={() => onAction('unarchive', artwork)} />
)}
<ActionBtn icon="fa-trash" title="Delete" onClick={() => onAction('delete', artwork)} danger />
</div>
</div>
</div>
{/* Info */}
<div className="p-3 space-y-2">
<h3 className="text-sm font-semibold text-white truncate" title={artwork.title}>
{artwork.title}
</h3>
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge status={status} />
<RisingBadge heatScore={artwork.heat_score} rankingScore={artwork.ranking_score} />
</div>
<div className="flex flex-wrap items-center gap-3">
{statItem('👁', artwork.views)}
{statItem('❤️', artwork.favourites)}
{statItem('🔗', artwork.shares)}
{statItem('💬', artwork.comments)}
{statItem('⬇', artwork.downloads)}
</div>
</div>
</div>
)
}
function ActionBtn({ icon, title, onClick, danger }) {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick() }}
title={title}
className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm transition-all backdrop-blur-sm ${
danger
? 'bg-red-500/20 text-red-400 hover:bg-red-500/40'
: 'bg-white/10 text-white hover:bg-white/20'
}`}
aria-label={title}
>
<i className={`fa-solid ${icon}`} />
</button>
)
}

View File

@@ -0,0 +1,144 @@
import React from 'react'
import StatusBadge from '../Badges/StatusBadge'
import RisingBadge from '../Badges/RisingBadge'
function getStatus(art) {
if (art.deleted_at) return 'archived'
if (!art.is_public) return 'draft'
return 'published'
}
export default function StudioTable({ artworks, selectedIds, onSelect, onSelectAll, onAction, onSort, currentSort }) {
const allSelected = artworks.length > 0 && artworks.every((a) => selectedIds.includes(a.id))
const columns = [
{ key: 'title', label: 'Title', sortable: false },
{ key: 'status', label: 'Status', sortable: false },
{ key: 'category', label: 'Category', sortable: false },
{ key: 'created_at', label: 'Created', sortable: true, sort: 'created_at' },
{ key: 'views', label: 'Views', sortable: true, sort: 'views' },
{ key: 'favourites', label: 'Favs', sortable: true, sort: 'favorites_count' },
{ key: 'shares', label: 'Shares', sortable: true, sort: 'shares_count' },
{ key: 'comments', label: 'Comments', sortable: true, sort: 'comments_count' },
{ key: 'downloads', label: 'Downloads', sortable: true, sort: 'downloads' },
{ key: 'ranking_score', label: 'Rank', sortable: true, sort: 'ranking_score' },
{ key: 'heat_score', label: 'Heat', sortable: true, sort: 'heat_score' },
]
const handleSort = (col) => {
if (!col.sortable) return
const field = col.sort
const [currentField, currentDir] = (currentSort || '').split(':')
const dir = currentField === field && currentDir === 'desc' ? 'asc' : 'desc'
onSort(`${field}:${dir}`)
}
const getSortIcon = (col) => {
if (!col.sortable) return null
const [currentField, currentDir] = (currentSort || '').split(':')
if (currentField !== col.sort) return <i className="fa-solid fa-sort text-slate-600 ml-1 text-[10px]" />
return <i className={`fa-solid fa-sort-${currentDir === 'asc' ? 'up' : 'down'} text-accent ml-1 text-[10px]`} />
}
return (
<div className="overflow-x-auto rounded-2xl border border-white/10 bg-nova-900/40">
<table className="w-full text-sm text-left">
<thead className="sticky top-0 z-10 bg-nova-900/90 backdrop-blur-sm border-b border-white/10">
<tr>
<th className="p-3 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
/>
</th>
<th className="p-3 w-12"></th>
{columns.map((col) => (
<th
key={col.key}
className={`p-3 text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap ${col.sortable ? 'cursor-pointer hover:text-white select-none' : ''}`}
onClick={() => handleSort(col)}
>
{col.label}
{getSortIcon(col)}
</th>
))}
<th className="p-3 w-20 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{artworks.map((art) => (
<tr
key={art.id}
className={`transition-colors ${selectedIds.includes(art.id) ? 'bg-accent/5' : 'hover:bg-white/[0.02]'}`}
>
<td className="p-3">
<input
type="checkbox"
checked={selectedIds.includes(art.id)}
onChange={() => onSelect(art.id)}
className="w-4 h-4 rounded-sm bg-transparent border border-white/20 accent-accent focus:ring-accent/50 cursor-pointer"
/>
</td>
<td className="p-3">
<img
src={art.thumb_url}
alt=""
className="w-10 h-10 rounded-lg object-cover bg-nova-800"
loading="lazy"
/>
</td>
<td className="p-3">
<span className="text-white font-medium truncate block max-w-[200px]" title={art.title}>{art.title}</span>
</td>
<td className="p-3"><StatusBadge status={getStatus(art)} /></td>
<td className="p-3 text-slate-400">{art.category || '—'}</td>
<td className="p-3 text-slate-400 whitespace-nowrap">{art.created_at ? new Date(art.created_at).toLocaleDateString() : '—'}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.views.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.favourites.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.shares.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.comments.toLocaleString()}</td>
<td className="p-3 text-slate-300 tabular-nums">{art.downloads.toLocaleString()}</td>
<td className="p-3">
<RisingBadge heatScore={0} rankingScore={art.ranking_score} />
<span className="text-slate-400 text-xs">{art.ranking_score.toFixed(1)}</span>
</td>
<td className="p-3">
<RisingBadge heatScore={art.heat_score} rankingScore={0} />
<span className="text-slate-400 text-xs">{art.heat_score.toFixed(1)}</span>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<button
onClick={() => onAction('edit', art)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-slate-400 hover:text-white hover:bg-white/10 transition-all"
title="Edit"
aria-label={`Edit ${art.title}`}
>
<i className="fa-solid fa-pen" />
</button>
<button
onClick={() => onAction('delete', art)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-all"
title="Delete"
aria-label={`Delete ${art.title}`}
>
<i className="fa-solid fa-trash" />
</button>
</div>
</td>
</tr>
))}
{artworks.length === 0 && (
<tr>
<td colSpan={14} className="p-12 text-center text-slate-500">
No artworks found
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import React from 'react'
const sortOptions = [
{ value: 'created_at:desc', label: 'Latest' },
{ value: 'ranking_score:desc', label: 'Trending' },
{ value: 'heat_score:desc', label: 'Rising' },
{ value: 'views:desc', label: 'Most viewed' },
{ value: 'favorites_count:desc', label: 'Most favourited' },
{ value: 'shares_count:desc', label: 'Most shared' },
{ value: 'downloads:desc', label: 'Most downloaded' },
]
export default function StudioToolbar({
search,
onSearchChange,
sort,
onSortChange,
viewMode,
onViewModeChange,
onFilterToggle,
selectedCount,
onUpload,
}) {
return (
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search title or tags…"
style={{ paddingLeft: '3rem' }}
className="w-full pr-4 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white placeholder-slate-500 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent/50 transition-all"
/>
</div>
{/* Sort */}
<select
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="px-3 py-2.5 rounded-xl bg-nova-900/60 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none cursor-pointer min-w-[160px]"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value} className="bg-nova-900 text-white">
{opt.label}
</option>
))}
</select>
{/* Filter toggle */}
<button
onClick={onFilterToggle}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
aria-label="Toggle filters"
>
<i className="fa-solid fa-filter" />
<span className="hidden sm:inline">Filters</span>
</button>
{/* View toggle */}
<div className="flex items-center bg-nova-900/60 border border-white/10 rounded-xl overflow-hidden">
<button
onClick={() => onViewModeChange('grid')}
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'grid' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
aria-label="Grid view"
>
<i className="fa-solid fa-table-cells" />
</button>
<button
onClick={() => onViewModeChange('list')}
className={`px-3 py-2.5 text-sm transition-all ${viewMode === 'list' ? 'bg-accent/20 text-accent' : 'text-slate-400 hover:text-white'}`}
aria-label="List view"
>
<i className="fa-solid fa-list" />
</button>
</div>
{/* Upload */}
<a
href="/upload"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25"
>
<i className="fa-solid fa-cloud-arrow-up" />
<span className="hidden sm:inline">Upload</span>
</a>
</div>
)
}

View File

@@ -53,6 +53,7 @@ export default function Topbar({ user = null }) {
</a>
<div className="border-t border-neutral-700" />
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
<div className="border-t border-neutral-700" />
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"

31
resources/js/studio.jsx Normal file
View File

@@ -0,0 +1,31 @@
import './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
// Eagerly import all Studio pages
import StudioDashboard from './Pages/Studio/StudioDashboard'
import StudioArtworks from './Pages/Studio/StudioArtworks'
import StudioDrafts from './Pages/Studio/StudioDrafts'
import StudioArchived from './Pages/Studio/StudioArchived'
import StudioArtworkAnalytics from './Pages/Studio/StudioArtworkAnalytics'
import StudioArtworkEdit from './Pages/Studio/StudioArtworkEdit'
import StudioAnalytics from './Pages/Studio/StudioAnalytics'
const pages = {
'Studio/StudioDashboard': StudioDashboard,
'Studio/StudioArtworks': StudioArtworks,
'Studio/StudioDrafts': StudioDrafts,
'Studio/StudioArchived': StudioArchived,
'Studio/StudioArtworkAnalytics': StudioArtworkAnalytics,
'Studio/StudioArtworkEdit': StudioArtworkEdit,
'Studio/StudioAnalytics': StudioAnalytics,
}
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
},
})

View File

@@ -51,9 +51,11 @@
.shadow-sb { box-shadow: 0 12px 30px rgba(0,0,0,.45) !important; }
/* Scrollbar helpers used in preview */
.sb-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; }
.sb-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,.08); border-radius: 999px; }
.sb-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,.15); }
.sb-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.sb-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 999px; transition: background .2s; }
.sb-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.25); }
.sb-scrollbar::-webkit-scrollbar-track { background: transparent; }
.sb-scrollbar { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.12) transparent; }
/* Ensure header and dropdowns are not clipped and render above page content */
header {

View File

@@ -147,6 +147,7 @@
</a>
<ul class="dropdown-menu">
<li><a href="/upload"><i class="fa fa-upload"></i> Upload</a></li>
<li><a href="/studio/artworks"><i class="fa fa-palette"></i> Studio</a></li>
<li><a href="{{ route('dashboard.artworks.index') }}"><i class="fa fa-cloud"></i> Edit Artworks</a></li>
<li role="presentation" class="divider"></li>
<li><a href="/statistics"><i class="fa fa-cog"></i> Statistics</a></li>

View File

@@ -19,7 +19,7 @@
<div class="ribbon gid_{{ $art->gid_num ?? 0 }}" title="{{ $art->category_name }}"><span>{{ $art->category_name }}</span></div>
@endif
<a href="/art/{{ $art->id }}/{{ Str::slug($art->name ?? '') }}" class="thumb-link" title="{{ $art->name }}">
<img src="{{ $art->thumb_url ?? '/gfx/sb_join.jpg' }}" @if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif alt="{{ $art->name }}" class="img-responsive" loading="lazy" decoding="async">
<img src="{{ $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}" @if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif alt="{{ $art->name }}" class="img-responsive" loading="lazy" decoding="async">
</a>
<div class="thumb-meta">
<div class="thumb-title">{{ $art->name }}</div>

View File

@@ -42,7 +42,7 @@
<td class="text-center">
<a href="/art/{{ (int) $art->id }}" title="View">
<img
src="{{ $art->thumb_url ?? '/gfx/sb_join.jpg' }}"
src="{{ $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}"
@if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif
alt="{{ $art->name ?? '' }}"
class="img-thumbnail"

View File

@@ -23,12 +23,12 @@
<a href="{{ $ar->art_url ?? ('/art/' . $ar->id) }}"
class="group relative block overflow-hidden rounded-xl ring-1 ring-white/5 bg-black/20 shadow-md transition-all duration-200 hover:-translate-y-0.5">
<div class="relative aspect-square overflow-hidden bg-neutral-900">
<img src="{{ $ar->thumb_url ?? '/gfx/sb_join.jpg' }}"
<img src="{{ $ar->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}"
alt="{{ $ar->name ?? '' }}"
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.06]"
onerror="this.src='/gfx/sb_join.jpg'">
onerror="this.src='https://files.skinbase.org/default/missing_md.webp'">
{{-- Title overlay on hover --}}
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-2 py-2
opacity-0 transition-opacity duration-200 group-hover:opacity-100">

View File

@@ -36,8 +36,8 @@
$imageObject = [
'@context' => 'https://schema.org',
'@type' => 'ImageObject',
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'name' => (string) $artwork->title,
'description' => (string) ($artwork->description ?? ''),
'url' => $meta['canonical'],
'contentUrl' => $meta['og_image'] ?? null,
'thumbnailUrl' => $presentMd['url'] ?? ($meta['og_image'] ?? null),
@@ -53,8 +53,8 @@
$creativeWork = [
'@context' => 'https://schema.org',
'@type' => 'CreativeWork',
'name' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'description' => html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'name' => (string) $artwork->title,
'description' => (string) ($artwork->description ?? ''),
'url' => $meta['canonical'],
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
'datePublished' => optional($artwork->published_at)->toAtomString(),

View File

@@ -11,7 +11,7 @@
@foreach($artworks as $art)
<div class="bg-panel p-3 rounded">
<a href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}">
<img src="{{ $art->thumbUrl('md') ?? '/gfx/sb_join.jpg' }}" alt="{{ $art->title }}" class="w-full h-36 object-cover rounded" />
<img src="{{ $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp' }}" alt="{{ $art->title }}" class="w-full h-36 object-cover rounded" />
</a>
<div class="mt-2 text-sm">
<a class="font-medium" href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}">{{ $art->title }}</a>

View File

@@ -40,6 +40,9 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/trending">
<i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/rising">
<i class="fa-solid fa-rocket w-4 text-center text-sb-muted"></i>Rising
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/fresh">
<i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh
</a>
@@ -216,74 +219,37 @@
@php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeDashboardGallery = Route::has('dashboard.gallery') ? route('dashboard.gallery') : '/dashboard/gallery';
$routeDashboardArtworks = Route::has('dashboard.artworks.index') ? route('dashboard.artworks.index') : (Route::has('dashboard.artworks') ? route('dashboard.artworks') : '/dashboard/artworks');
$routeDashboardStats = Route::has('legacy.statistics') ? route('legacy.statistics') : '/statistics';
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
$routeDashboardAwards = Route::has('dashboard.awards') ? route('dashboard.awards') : '/dashboard/awards';
$routeDashboardFollowers = Route::has('dashboard.followers') ? route('dashboard.followers') : '/dashboard/followers';
$routeDashboardFollowing = Route::has('dashboard.following') ? route('dashboard.following') : '/dashboard/following';
$routeDashboardComments = Route::has('dashboard.comments') ? route('dashboard.comments') : '/dashboard/comments';
$routeDashboardProfile = Route::has('dashboard.profile') ? route('dashboard.profile') : '/dashboard/profile';
$routeEditProfile = Route::has('settings') ? route('settings') : '/settings';
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
@endphp
{{-- My Content --}}
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Content</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeUpload }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span>
Upload
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardGallery }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-image text-xs text-sb-muted"></i></span>
My Gallery
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/studio/artworks">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
Studio
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-heart text-xs text-sb-muted"></i></span>
My Favorites
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardAwards }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-trophy text-xs text-sb-muted"></i></span>
My Awards
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardStats }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-chart-line text-xs text-sb-muted"></i></span>
Statistics
</a>
{{-- Community --}}
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Community</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowers }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-group text-xs text-sb-muted"></i></span>
Followers
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowing }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-plus text-xs text-sb-muted"></i></span>
Following
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardComments }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-comments text-xs text-sb-muted"></i></span>
My Activity
</a>
<div class="border-t border-panel my-1"></div>
{{-- Account --}}
<div class="px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Account</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-eye text-xs text-sb-muted"></i></span>
View Profile
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeEditProfile }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-pen text-xs text-sb-muted"></i></span>
Edit Profile
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardProfile }}">
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeEditProfile }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-cog text-xs text-sb-muted"></i></span>
Settings
</a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@@ -323,6 +289,7 @@
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Discover</div>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/trending"><i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/rising"><i class="fa-solid fa-rocket w-4 text-center text-sb-muted"></i>Rising</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/fresh"><i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
@@ -367,16 +334,16 @@
$mobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $mobileUsername]) : '/@'.$mobileUsername;
@endphp
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Account</div>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/studio/artworks">
<i class="fa-solid fa-palette w-4 text-center text-sb-muted"></i>Studio
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites' }}">
<i class="fa-solid fa-heart w-4 text-center text-sb-muted"></i>My Favorites
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ $mobileProfile }}">
<i class="fa-solid fa-circle-user w-4 text-center text-sb-muted"></i>View Profile
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.awards') }}">
<i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>My Awards
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('settings') ? route('settings') : '/settings' }}">
<i class="fa-solid fa-pen w-4 text-center text-sb-muted"></i>Edit Profile
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.profile') }}">
<i class="fa-solid fa-cog w-4 text-center text-sb-muted"></i>Settings
</a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))

View File

@@ -1,271 +0,0 @@
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel">
<div class="mx-auto w-full h-full px-4 flex items-center gap-3">
<!-- Mobile hamburger -->
<button id="btnSidebar"
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5">
<!-- bars -->
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Logo -->
<a href="/" class="flex items-center gap-2 pr-2">
<img src="/gfx/sb_logo.png" alt="Skinbase.org" class="h-8 w-auto rounded-sm shadow-sm object-contain">
<span class="sr-only">Skinbase.org</span>
</a>
<!-- Left nav -->
<nav class="hidden lg:flex items-center gap-4 text-sm text-soft">
<div class="relative">
<button class="hover:text-white inline-flex items-center gap-1" data-dd="browse">
Browse
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
</button>
<div id="dd-browse" class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-visible">
<div class="rounded-lg overflow-hidden">
<div class="px-4 dd-section">Views</div>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Forum</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/chat"><i class="fa-solid fa-message mr-3 text-sb-muted"></i>Chat</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/sections"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Browse Sections</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/latest"><i class="fa-solid fa-cloud-arrow-up mr-3 text-sb-muted"></i>Latest Uploads</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/uploads/daily"><i class="fa-solid fa-calendar-day mr-3 text-sb-muted"></i>Daily Uploads</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/today-in-history"><i class="fa-solid fa-calendar mr-3 text-sb-muted"></i>Today In History</a>
<div class="px-4 dd-section">Authors</div>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/interviews"><i class="fa-solid fa-microphone mr-3 text-sb-muted"></i>Interviews</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/members/photos"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Members Photos</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/authors/top"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Top Authors</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/latest"><i class="fa-solid fa-comments mr-3 text-sb-muted"></i>Latest Comments</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/comments/monthly"><i class="fa-solid fa-chart-line mr-3 text-sb-muted"></i>Monthly Commented</a>
<div class="px-4 dd-section">Statistics</div>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/downloads/today"><i class="fa-solid fa-download mr-3 text-sb-muted"></i>Todays Downloads</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/favourites/top"><i class="fa-solid fa-heart mr-3 text-sb-muted"></i>Top Favourites</a>
</div> <!-- end .rounded-lg -->
</div> <!-- end .dd-browse -->
</div> <!-- end .relative -->
<div class="relative">
<button class="hover:text-white inline-flex items-center gap-1" data-dd="cats">
Explore
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div id="dd-cats"
class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all mr-3 text-sb-muted"></i>All Artworks</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Photography</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop mr-3 text-sb-muted"></i>Wallpapers</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group mr-3 text-sb-muted"></i>Skins</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Other</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/featured-artworks"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Featured Artwork</a>
</div>
</div>
</nav>
<!-- Search -->
<div class="flex-1 flex items-center justify-center">
<div class="w-full max-w-lg">
<div id="topbar-search-root"></div>
</div>
</div>
@auth
<!-- Right icon counters (authenticated users) -->
<div class="hidden md:flex items-center gap-3 text-soft">
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M12 21s-7-4.4-9-9a5.5 5.5 0 0 1 9-6 5.5 5.5 0 0 1 9 6c-2 4.6-9 9-9 9z" />
</svg>
<span
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $uploadCount ?? 0 }}</span>
</button>
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M4 4h16v14H5.2L4 19.2V4z" />
<path d="M4 6l8 6 8-6" />
</svg>
<span
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $favCount ?? 0 }}</span>
</button>
<button class="relative w-10 h-10 rounded-lg hover:bg-white/5">
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg>
<span
class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $msgCount ?? 0 }}</span>
</button>
<!-- User dropdown -->
<div class="relative">
<button class="flex items-center gap-2 pl-2 pr-3 h-10 rounded-lg hover:bg-white/5" data-dd="user">
@php
$toolbarUserId = (int) ($userId ?? Auth::id() ?? 0);
$toolbarAvatarHash = $avatarHash ?? optional(Auth::user())->profile->avatar_hash ?? null;
@endphp
<img class="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
src="{{ \App\Support\AvatarUrl::forUser($toolbarUserId, $toolbarAvatarHash, 64) }}"
alt="{{ $displayName ?? 'User' }}" />
<span class="text-sm text-white/90">{{ $displayName ?? 'User' }}</span>
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div id="dd-user"
class="hidden absolute right-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
@php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeDashboardUpload = Route::has('dashboard.upload') ? route('dashboard.upload') : route('upload');
$routeDashboardGallery = Route::has('dashboard.gallery') ? route('dashboard.gallery') : '/dashboard/gallery';
$routeDashboardArtworks = Route::has('dashboard.artworks') ? route('dashboard.artworks') : (Route::has('dashboard.artworks.index') ? route('dashboard.artworks.index') : '/dashboard/artworks');
$routeDashboardStats = Route::has('dashboard.stats') ? route('dashboard.stats') : (Route::has('legacy.statistics') ? route('legacy.statistics') : '/dashboard/stats');
$routeDashboardFollowers = Route::has('dashboard.followers') ? route('dashboard.followers') : '/dashboard/followers';
$routeDashboardFollowing = Route::has('dashboard.following') ? route('dashboard.following') : '/dashboard/following';
$routeDashboardComments = Route::has('dashboard.comments') ? route('dashboard.comments') : '/dashboard/comments';
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
$routeDashboardProfile = Route::has('dashboard.profile') ? route('dashboard.profile') : (Route::has('profile.edit') ? route('profile.edit') : '/dashboard/profile');
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
@endphp
<div class="px-4 dd-section">My Account</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardUpload }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-upload text-sb-muted"></i></span>
Upload
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardGallery }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-image text-sb-muted"></i></span>
My Gallery
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardArtworks }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-pencil text-sb-muted"></i></span>
Edit Artworks
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardStats }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-chart-line text-sb-muted"></i></span>
Statistics
</a>
<div class="px-4 dd-section">Community</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowers }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-user-group text-sb-muted"></i></span>
Followers
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFollowing }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-user-plus text-sb-muted"></i></span>
Following
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardComments }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-comments text-sb-muted"></i></span>
Comments
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardFavorites }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-heart text-sb-muted"></i></span>
Favourites
</a>
<div class="px-4 dd-section">Community</div>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routePublicProfile }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-eye text-sb-muted"></i></span>
View My Profile
</a>
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ $routeDashboardProfile }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-cog text-sb-muted"></i></span>
Edit Profile
</a>
<div class="px-4 dd-section">System</div>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-user-shield text-sb-muted"></i></span>
Username Moderation
</a>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="w-full text-left flex items-center gap-3 px-4 py-2 text-sm hover:bg-white/5">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center mr-3"><i
class="fa-solid fa-sign-out text-sb-muted"></i></span>
Logout
</button>
</form>
</div>
</div>
</div>
@else
<!-- Guest: show simple Join / Sign in links -->
<div class="hidden md:flex items-center gap-3">
<a href="/register"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Join</a>
<a href="/login"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5">Sign in</a>
</div>
@endauth
</div>
</header>
<!-- MOBILE MENU -->
<div class="hidden fixed top-16 left-0 right-0 bg-neutral-950 border-b border-neutral-800 p-4" id="mobileMenu">
<div class="space-y-2">
@guest
<a class="block py-2 border-b border-neutral-900" href="/signup">Join</a>
<a class="block py-2 border-b border-neutral-900" href="/login">Sign in</a>
@endguest
<a class="block py-2 border-b border-neutral-900" href="/browse">All Artworks</a>
<a class="block py-2 border-b border-neutral-900" href="/photography">Photography</a>
<a class="block py-2 border-b border-neutral-900" href="/wallpapers">Wallpapers</a>
<a class="block py-2 border-b border-neutral-900" href="/skins">Skins</a>
<a class="block py-2 border-b border-neutral-900" href="/other">Other</a>
<a class="block py-2 border-b border-neutral-900" href="/featured-artworks">Featured</a>
<a class="block py-2 border-b border-neutral-900" href="/forum">Forum</a>
@auth
@php
$toolbarMobileUsername = strtolower((string) (Auth::user()->username ?? ''));
$toolbarMobileProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarMobileUsername]) : '/@'.$toolbarMobileUsername;
@endphp
<a class="block py-2 border-b border-neutral-900" href="{{ $toolbarMobileProfile }}">Profile</a>
@else
<a class="block py-2 border-b border-neutral-900" href="/profile">Profile</a>
@endauth
@auth
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="block py-2 border-b border-neutral-900" href="{{ route('admin.usernames.moderation') }}">Username Moderation</a>
@endif
@endauth
<a class="block py-2" href="/settings">Settings</a>
</div>
</div>

View File

@@ -0,0 +1,18 @@
@extends('layouts.nova')
@push('head')
<meta name="csrf-token" content="{{ csrf_token() }}" />
@vite(['resources/js/studio.jsx'])
<style>
body.page-studio main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-studio')
})
</script>
@endpush
@section('content')
@inertia
@endsection

View File

@@ -28,6 +28,7 @@
@php
$sections = [
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket'],
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],

View File

@@ -26,7 +26,7 @@
@php
$card = (object) [
'url' => url('/art/' . ($art->id ?? '') . '/' . \Illuminate\Support\Str::slug($art->name ?? '')),
'thumb' => $art->thumb_url ?? '/gfx/sb_join.jpg',
'thumb' => $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp',
'thumb_srcset' => $art->thumb_srcset ?? null,
'name' => $art->name ?? '',
'uname' => $art->uname ?? 'Unknown',

View File

@@ -44,6 +44,18 @@ Route::prefix('rank')->name('api.rank.')->middleware(['throttle:60,1'])->group(f
* GET /api/v1/artworks/{slug}
* GET /api/v1/categories/{slug}/artworks
*/
// ── Studio Pro API (authenticated) ─────────────────────────────────────────────
Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group(function () {
Route::get('artworks', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'index'])->name('artworks.index');
Route::post('artworks/bulk', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'bulk'])->name('artworks.bulk');
Route::put('artworks/{id}', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'update'])->whereNumber('id')->name('artworks.update');
Route::post('artworks/{id}/toggle', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'toggle'])->whereNumber('id')->name('artworks.toggle');
Route::get('artworks/{id}/analytics', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'analytics'])->whereNumber('id')->name('artworks.analytics');
Route::post('artworks/{id}/replace-file', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'replaceFile'])->whereNumber('id')->name('artworks.replaceFile');
Route::get('tags/search', [\App\Http\Controllers\Studio\StudioArtworksApiController::class, 'searchTags'])->name('tags.search');
});
Route::prefix('v1')->name('api.v1.')->group(function () {
// Public browse feed (authoritative tables only)
Route::get('browse', [\App\Http\Controllers\Api\BrowseController::class, 'index'])

View File

@@ -64,6 +64,29 @@ Schedule::command('skinbase:prune-view-events --days=90')
->name('prune-view-events')
->withoutOverlapping();
// ── Similar Artworks (Hybrid Recommender) ──────────────────────────────────────
// Build co-occurrence pairs from favourites every 4 hours.
Schedule::job(new \App\Jobs\RecBuildItemPairsFromFavouritesJob())
->everyFourHours()
->name('rec-build-item-pairs')
->withoutOverlapping();
// Nightly: recompute tag, behavior, and hybrid similarity lists.
Schedule::job(new \App\Jobs\RecComputeSimilarByTagsJob())
->dailyAt('02:00')
->name('rec-compute-tags')
->withoutOverlapping();
Schedule::job(new \App\Jobs\RecComputeSimilarByBehaviorJob())
->dailyAt('02:15')
->name('rec-compute-behavior')
->withoutOverlapping();
Schedule::job(new \App\Jobs\RecComputeSimilarHybridJob())
->dailyAt('02:30')
->name('rec-compute-hybrid')
->withoutOverlapping();
// ── Ranking Engine V2 ──────────────────────────────────────────────────────────
// Recalculate ranking_score + engagement_velocity every 30 minutes.
// Also syncs V2 scores to rank_artwork_scores so list builds benefit.

View File

@@ -38,6 +38,7 @@ use Inertia\Inertia;
// ── DISCOVER routes (/discover/*) ─────────────────────────────────────────────
Route::prefix('discover')->name('discover.')->group(function () {
Route::get('/trending', [DiscoverController::class, 'trending'])->name('trending');
Route::get('/rising', [DiscoverController::class, 'rising'])->name('rising');
Route::get('/fresh', [DiscoverController::class, 'fresh'])->name('fresh');
Route::get('/top-rated', [DiscoverController::class, 'topRated'])->name('top-rated');
Route::get('/most-downloaded', [DiscoverController::class, 'mostDownloaded'])->name('most-downloaded');
@@ -236,6 +237,18 @@ Route::middleware(['auth', \App\Http\Middleware\NoIndexDashboard::class])->prefi
Route::get('/awards', [\App\Http\Controllers\Dashboard\DashboardAwardsController::class, 'index'])->name('awards');
});
// ── Studio Pro (Creator Artwork Manager) ────────────────────────────────────
use App\Http\Controllers\Studio\StudioController;
Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->name('studio.')->group(function () {
Route::get('/', [StudioController::class, 'index'])->name('index');
Route::get('/artworks', [StudioController::class, 'artworks'])->name('artworks');
Route::get('/artworks/drafts', [StudioController::class, 'drafts'])->name('drafts');
Route::get('/artworks/archived', [StudioController::class, 'archived'])->name('archived');
Route::get('/artworks/{id}/edit', [StudioController::class, 'edit'])->whereNumber('id')->name('artworks.edit');
Route::get('/artworks/{id}/analytics', [StudioController::class, 'analytics'])->whereNumber('id')->name('artworks.analytics');
Route::get('/analytics', [StudioController::class, 'analyticsOverview'])->name('analytics');
});
Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])->group(function () {
// Redirect legacy `/profile` edit path to canonical dashboard profile route.
Route::get('/profile', function () {

View File

@@ -0,0 +1,42 @@
<?php
use App\Services\ArtworkService;
use Illuminate\Pagination\LengthAwarePaginator;
beforeEach(function () {
$this->artworksMock = Mockery::mock(ArtworkService::class);
$this->artworksMock->shouldReceive('getFeaturedArtworks')
->andReturn(new LengthAwarePaginator(collect(), 0, 20, 1))
->byDefault();
$this->artworksMock->shouldReceive('getLatestArtworks')
->andReturn(collect())
->byDefault();
$this->app->instance(ArtworkService::class, $this->artworksMock);
});
it('GET /discover/rising returns 200', function () {
$this->get('/discover/rising')
->assertStatus(200);
});
it('/discover/rising page contains Rising Now heading', function () {
$this->get('/discover/rising')
->assertStatus(200)
->assertSee('Rising Now', false);
});
it('/discover/rising page includes the rising section pill as active', function () {
$this->get('/discover/rising')
->assertStatus(200)
->assertSee('bg-sky-600', false);
});
it('GET /discover/trending still returns 200', function () {
$this->get('/discover/trending')
->assertStatus(200);
});
it('home page still renders with rising section data', function () {
$this->get('/')
->assertStatus(200);
});

View File

@@ -0,0 +1,430 @@
<?php
declare(strict_types=1);
use App\Jobs\RecBuildItemPairsFromFavouritesJob;
use App\Models\Artwork;
use App\Models\ArtworkFavourite;
use App\Models\Category;
use App\Models\RecArtworkRec;
use App\Models\RecItemPair;
use App\Models\Tag;
use App\Models\User;
use App\Services\Recommendations\HybridSimilarArtworksService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['scout.driver' => 'null']);
Cache::flush();
});
// ─── Helper ────────────────────────────────────────────────────────────────────
function createPublicArtwork(array $attrs = []): Artwork
{
return Artwork::withoutEvents(function () use ($attrs) {
return Artwork::factory()->create(array_merge([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
], $attrs));
});
}
// ─── API returns fallback if precomputed list is missing ───────────────────────
it('returns fallback results when no precomputed similar list exists', function () {
$artwork = createPublicArtwork();
// Create some other artworks so the trending fallback can find them
$other1 = createPublicArtwork(['published_at' => now()->subMinutes(10)]);
$other2 = createPublicArtwork(['published_at' => now()->subMinutes(20)]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12);
// Should still return artworks via trending fallback, not an empty set
expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class)
->and($result)->not->toBeEmpty();
});
it('returns empty collection for non-existent artwork', function () {
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork(999999, 12);
expect($result)->toBeEmpty();
});
it('returns similar_tags list when hybrid is missing', function () {
$artwork = createPublicArtwork();
$similar1 = createPublicArtwork();
$similar2 = createPublicArtwork();
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_tags',
'model_version' => 'sim_v1',
'recs' => [$similar1->id, $similar2->id],
'computed_at' => now(),
]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12);
expect($result)->toHaveCount(2)
->and($result->pluck('id')->all())->toEqual([$similar1->id, $similar2->id]);
});
// ─── Ordering is preserved ─────────────────────────────────────────────────────
it('preserves precomputed ordering exactly', function () {
$artwork = createPublicArtwork();
$a = createPublicArtwork();
$b = createPublicArtwork();
$c = createPublicArtwork();
$d = createPublicArtwork();
// Deliberate non-sequential order
$orderedIds = [$c->id, $a->id, $d->id, $b->id];
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_hybrid',
'model_version' => 'sim_v1',
'recs' => $orderedIds,
'computed_at' => now(),
]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12);
expect($result->pluck('id')->all())->toEqual($orderedIds);
});
it('falls through from hybrid to tags preserving order', function () {
$artwork = createPublicArtwork();
$a = createPublicArtwork();
$b = createPublicArtwork();
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_tags',
'model_version' => 'sim_v1',
'recs' => [$b->id, $a->id],
'computed_at' => now(),
]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12);
expect($result->pluck('id')->all())->toEqual([$b->id, $a->id]);
});
// ─── Diversity cap (max per author) is enforced ────────────────────────────────
it('enforces author diversity cap at runtime', function () {
$artwork = createPublicArtwork();
// One author with 4 artworks
$author = User::factory()->create();
$sameAuthor1 = createPublicArtwork(['user_id' => $author->id]);
$sameAuthor2 = createPublicArtwork(['user_id' => $author->id]);
$sameAuthor3 = createPublicArtwork(['user_id' => $author->id]);
$sameAuthor4 = createPublicArtwork(['user_id' => $author->id]);
// Another author with 1 artwork
$otherAuthor = User::factory()->create();
$diffAuthor = createPublicArtwork(['user_id' => $otherAuthor->id]);
// Put all 5 in the precomputed list — same author dominates
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_hybrid',
'model_version' => 'sim_v1',
'recs' => [
$sameAuthor1->id,
$sameAuthor2->id,
$sameAuthor3->id,
$sameAuthor4->id,
$diffAuthor->id,
],
'computed_at' => now(),
]);
config(['recommendations.similarity.max_per_author' => 2]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12);
// Max 2 from same author, 1 from different author = 3 total
$resultByAuthor = $result->groupBy('user_id');
foreach ($resultByAuthor as $authorId => $artworks) {
expect($artworks->count())->toBeLessThanOrEqual(2);
}
expect($result)->toHaveCount(3);
});
// ─── Pair building doesn't explode per user ────────────────────────────────────
it('caps pairs per user to avoid combinatorial explosion', function () {
$user = User::factory()->create();
// Create exactly 5 artworks with favourites (bypass observers to avoid SQLite GREATEST issue)
$artworks = [];
for ($i = 0; $i < 5; $i++) {
$art = createPublicArtwork();
$artworks[] = $art;
DB::table('artwork_favourites')->insert([
'user_id' => $user->id,
'artwork_id' => $art->id,
'created_at' => now()->subMinutes($i),
'updated_at' => now()->subMinutes($i),
]);
}
$job = new RecBuildItemPairsFromFavouritesJob();
$pairs = $job->pairsForUser($user->id, 5);
// C(5,2) = 10 pairs max
expect($pairs)->toHaveCount(10);
// Verify each pair is ordered (a < b)
foreach ($pairs as [$a, $b]) {
expect($a)->toBeLessThan($b);
}
});
it('respects the favourites cap for pair generation', function () {
$user = User::factory()->create();
// Create 10 favourites (bypass observers)
for ($i = 0; $i < 10; $i++) {
$art = createPublicArtwork();
DB::table('artwork_favourites')->insert([
'user_id' => $user->id,
'artwork_id' => $art->id,
'created_at' => now()->subMinutes($i),
'updated_at' => now()->subMinutes($i),
]);
}
// Cap at 3 → C(3,2) = 3 pairs
$job = new RecBuildItemPairsFromFavouritesJob();
$pairs = $job->pairsForUser($user->id, 3);
expect($pairs)->toHaveCount(3);
});
it('returns empty pairs for user with only one favourite', function () {
$user = User::factory()->create();
$art = createPublicArtwork();
DB::table('artwork_favourites')->insert([
'user_id' => $user->id,
'artwork_id' => $art->id,
'created_at' => now(),
'updated_at' => now(),
]);
$job = new RecBuildItemPairsFromFavouritesJob();
$pairs = $job->pairsForUser($user->id, 50);
expect($pairs)->toBeEmpty();
});
// ─── API endpoint integration ──────────────────────────────────────────────────
it('returns JSON response from API endpoint with precomputed data', function () {
$artwork = createPublicArtwork();
$similar = createPublicArtwork();
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_hybrid',
'model_version' => 'sim_v1',
'recs' => [$similar->id],
'computed_at' => now(),
]);
$response = $this->getJson("/api/art/{$artwork->id}/similar");
$response->assertOk()
->assertJsonStructure(['data'])
->assertJsonCount(1, 'data');
});
it('returns 404 for non-existent artwork in API', function () {
$response = $this->getJson('/api/art/999999/similar');
$response->assertNotFound();
});
// ─── RecArtworkRec model ───────────────────────────────────────────────────────
it('stores and retrieves rec list with correct types', function () {
$artwork = createPublicArtwork();
$ids = [10, 20, 30];
$rec = RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_hybrid',
'model_version' => 'sim_v1',
'recs' => $ids,
'computed_at' => now(),
]);
$fresh = RecArtworkRec::find($rec->id);
expect($fresh->recs)->toBeArray()
->and($fresh->recs)->toEqual($ids)
->and($fresh->artwork_id)->toBe($artwork->id);
});
// ─── Fallback priority ─────────────────────────────────────────────────────────
it('chooses similar_behavior when tags and hybrid are missing', function () {
$artwork = createPublicArtwork();
$beh1 = createPublicArtwork();
$beh2 = createPublicArtwork();
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_behavior',
'model_version' => 'sim_v1',
'recs' => [$beh1->id, $beh2->id],
'computed_at' => now(),
]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12);
expect($result->pluck('id')->all())->toEqual([$beh1->id, $beh2->id]);
});
it('filters out unpublished artworks from precomputed list', function () {
$artwork = createPublicArtwork();
$published = createPublicArtwork();
$unpublished = Artwork::withoutEvents(function () {
return Artwork::factory()->unpublished()->create();
});
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_hybrid',
'model_version' => 'sim_v1',
'recs' => [$unpublished->id, $published->id],
'computed_at' => now(),
]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12);
expect($result->pluck('id')->all())->toEqual([$published->id]);
});
// ─── Type query param support (spec §8) ────────────────────────────────────────
it('returns specific rec type when ?type=tags is passed', function () {
$artwork = createPublicArtwork();
$tagSimilar = createPublicArtwork();
$behSimilar = createPublicArtwork();
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_tags',
'model_version' => 'sim_v1',
'recs' => [$tagSimilar->id],
'computed_at' => now(),
]);
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_behavior',
'model_version' => 'sim_v1',
'recs' => [$behSimilar->id],
'computed_at' => now(),
]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12, 'tags');
expect($result->pluck('id')->all())->toEqual([$tagSimilar->id]);
});
it('returns behavior list when ?type=behavior is passed', function () {
$artwork = createPublicArtwork();
$behSimilar = createPublicArtwork();
$tagSimilar = createPublicArtwork();
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_tags',
'model_version' => 'sim_v1',
'recs' => [$tagSimilar->id],
'computed_at' => now(),
]);
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_behavior',
'model_version' => 'sim_v1',
'recs' => [$behSimilar->id],
'computed_at' => now(),
]);
$service = app(HybridSimilarArtworksService::class);
$result = $service->forArtwork($artwork->id, 12, 'behavior');
expect($result->pluck('id')->all())->toEqual([$behSimilar->id]);
});
it('passes type query param from API endpoint', function () {
$artwork = createPublicArtwork();
$tagSimilar = createPublicArtwork();
RecArtworkRec::create([
'artwork_id' => $artwork->id,
'rec_type' => 'similar_tags',
'model_version' => 'sim_v1',
'recs' => [$tagSimilar->id],
'computed_at' => now(),
]);
$response = $this->getJson("/api/art/{$artwork->id}/similar?type=tags");
$response->assertOk()
->assertJsonCount(1, 'data');
});
// ─── Cosine normalized pair weights ────────────────────────────────────────────
it('produces cosine-normalized weights in pair builder', function () {
// User A: likes artwork 1, 2
$userA = User::factory()->create();
$art1 = createPublicArtwork();
$art2 = createPublicArtwork();
DB::table('artwork_favourites')->insert([
['user_id' => $userA->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
['user_id' => $userA->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
]);
// User B: also likes artwork 1, 2
$userB = User::factory()->create();
DB::table('artwork_favourites')->insert([
['user_id' => $userB->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
['user_id' => $userB->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
]);
$job = new RecBuildItemPairsFromFavouritesJob();
$job->handle();
$pair = RecItemPair::query()
->where('a_artwork_id', min($art1->id, $art2->id))
->where('b_artwork_id', max($art1->id, $art2->id))
->first();
expect($pair)->not->toBeNull();
// co_like = 2 (both users liked both), likes_A = 2, likes_B = 2
// S_beh = 2 / sqrt(2 * 2) = 2 / 2 = 1.0
expect($pair->weight)->toBe(1.0);
});

View File

@@ -0,0 +1,278 @@
<?php
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\ArtworkMetricSnapshotHourly;
use Illuminate\Foundation\Testing\RefreshDatabase;
/**
* Helper: create an artwork row without triggering observers (avoids GREATEST() SQLite issue).
*/
function createArtworkWithoutObserver(array $attrs = []): Artwork
{
return Artwork::withoutEvents(function () use ($attrs) {
return Artwork::factory()->create($attrs);
});
}
// ─── Snapshot Collection Command ───────────────────────────────────────────
it('nova:metrics-snapshot-hourly runs without errors', function () {
$this->artisan('nova:metrics-snapshot-hourly --dry-run')
->assertSuccessful();
});
it('creates snapshot rows for eligible artworks', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subDay(),
]);
ArtworkStats::upsert([
[
'artwork_id' => $artwork->id,
'views' => 100,
'downloads' => 10,
'favorites' => 5,
'comments_count' => 2,
'shares_count' => 1,
],
], ['artwork_id']);
$this->artisan('nova:metrics-snapshot-hourly')
->assertSuccessful();
$snapshot = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->first();
expect($snapshot)->not->toBeNull();
expect((int) $snapshot->views_count)->toBe(100);
expect((int) $snapshot->downloads_count)->toBe(10);
expect((int) $snapshot->favourites_count)->toBe(5);
});
it('upserts on duplicate bucket_hour', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subDay(),
]);
ArtworkStats::upsert([
[
'artwork_id' => $artwork->id,
'views' => 50,
'downloads' => 5,
'favorites' => 2,
],
], ['artwork_id']);
// Run twice — should not throw
$this->artisan('nova:metrics-snapshot-hourly')->assertSuccessful();
// Update stats and run again
ArtworkStats::where('artwork_id', $artwork->id)->update(['views' => 75]);
$this->artisan('nova:metrics-snapshot-hourly')->assertSuccessful();
$count = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count();
expect($count)->toBe(1); // upserted, not duplicated
$snapshot = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->first();
expect((int) $snapshot->views_count)->toBe(75);
});
// ─── Heat Recalculation Command ────────────────────────────────────────────
it('nova:recalculate-heat runs without errors', function () {
$this->artisan('nova:recalculate-heat --dry-run')
->assertSuccessful();
});
it('computes heat_score from snapshot deltas', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subHours(2),
]);
ArtworkStats::upsert([
['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
], ['artwork_id']);
$prevHour = now()->startOfHour()->subHour();
$currentHour = now()->startOfHour();
// Previous hour snapshot
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => $prevHour,
'views_count' => 10,
'downloads_count' => 2,
'favourites_count' => 1,
'comments_count' => 0,
'shares_count' => 0,
]);
// Current hour snapshot (engagement grew)
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => $currentHour,
'views_count' => 30,
'downloads_count' => 5,
'favourites_count' => 4,
'comments_count' => 2,
'shares_count' => 1,
]);
$this->artisan('nova:recalculate-heat')
->assertSuccessful();
$stat = ArtworkStats::where('artwork_id', $artwork->id)->first();
expect((float) $stat->heat_score)->toBeGreaterThan(0);
// Verify delta values cached on stats
expect((int) $stat->views_1h)->toBe(20); // 30 - 10
expect((int) $stat->downloads_1h)->toBe(3); // 5 - 2
expect((int) $stat->favourites_1h)->toBe(3); // 4 - 1
expect((int) $stat->comments_1h)->toBe(2); // 2 - 0
expect((int) $stat->shares_1h)->toBe(1); // 1 - 0
});
it('handles negative deltas gracefully by clamping to zero', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subHour(),
]);
ArtworkStats::upsert([
['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
], ['artwork_id']);
$prevHour = now()->startOfHour()->subHour();
$currentHour = now()->startOfHour();
// Simulate counter reset: current < previous
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => $prevHour,
'views_count' => 100,
'downloads_count' => 50,
'favourites_count' => 20,
'comments_count' => 10,
'shares_count' => 5,
]);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => $currentHour,
'views_count' => 50, // < prev
'downloads_count' => 30, // < prev
'favourites_count' => 10, // < prev
'comments_count' => 5, // < prev
'shares_count' => 2, // < prev
]);
$this->artisan('nova:recalculate-heat')
->assertSuccessful();
$stat = ArtworkStats::where('artwork_id', $artwork->id)->first();
expect((float) $stat->heat_score)->toBe(0.0); // all deltas negative → clamped to 0
expect((int) $stat->views_1h)->toBe(0);
expect((int) $stat->downloads_1h)->toBe(0);
});
// ─── Pruning Command ──────────────────────────────────────────────────────
it('nova:prune-metric-snapshots removes old data', function () {
$artwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subDays(30),
]);
// Old snapshot (10 days ago)
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => now()->subDays(10)->startOfHour(),
'views_count' => 50,
'downloads_count' => 5,
'favourites_count' => 2,
'comments_count' => 0,
'shares_count' => 0,
]);
// Recent snapshot (1 hour ago)
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $artwork->id,
'bucket_hour' => now()->subHour()->startOfHour(),
'views_count' => 100,
'downloads_count' => 10,
'favourites_count' => 5,
'comments_count' => 1,
'shares_count' => 0,
]);
$this->artisan('nova:prune-metric-snapshots --keep-days=7')
->assertSuccessful();
$remaining = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count();
expect($remaining)->toBe(1); // only the recent one survives
});
// ─── Heat Formula Unit Check ───────────────────────────────────────────────
it('heat formula applies age factor correctly', function () {
// Newer artwork should get higher heat than older one with same deltas
$newArtwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subHours(1),
'created_at' => now()->subHours(1),
]);
$oldArtwork = createArtworkWithoutObserver([
'is_approved' => true,
'is_public' => true,
'published_at' => now()->subDays(30),
'created_at' => now()->subDays(30),
]);
$prevHour = now()->startOfHour()->subHour();
$currentHour = now()->startOfHour();
foreach ([$newArtwork, $oldArtwork] as $art) {
ArtworkStats::upsert([
['artwork_id' => $art->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
], ['artwork_id']);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $art->id,
'bucket_hour' => $prevHour,
'views_count' => 0,
'downloads_count' => 0,
'favourites_count' => 0,
'comments_count' => 0,
'shares_count' => 0,
]);
ArtworkMetricSnapshotHourly::create([
'artwork_id' => $art->id,
'bucket_hour' => $currentHour,
'views_count' => 100,
'downloads_count' => 10,
'favourites_count' => 5,
'comments_count' => 3,
'shares_count' => 2,
]);
}
$this->artisan('nova:recalculate-heat')->assertSuccessful();
$newStat = ArtworkStats::where('artwork_id', $newArtwork->id)->first();
$oldStat = ArtworkStats::where('artwork_id', $oldArtwork->id)->first();
expect((float) $newStat->heat_score)->toBeGreaterThan(0);
expect((float) $oldStat->heat_score)->toBeGreaterThan(0);
// Newer artwork should have higher heat score due to age factor
expect((float) $newStat->heat_score)->toBeGreaterThan((float) $oldStat->heat_score);
});

View File

@@ -0,0 +1,236 @@
<?php
use App\Models\User;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use Illuminate\Support\Facades\DB;
/**
* Helper: create an artwork without triggering observers (avoids GREATEST() SQLite issue).
*/
function studioArtwork(array $attrs = []): Artwork
{
return Artwork::withoutEvents(fn () => Artwork::factory()->create($attrs));
}
beforeEach(function () {
// Register GREATEST() polyfill for SQLite (used by observers on user_statistics)
if (DB::connection()->getDriverName() === 'sqlite') {
DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) {
return max($args);
}, -1);
}
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
// ── Route Auth Tests ──────────────────────────────────────────────────────────
test('studio routes require authentication', function () {
auth()->logout();
$routes = [
'/studio',
'/studio/artworks',
'/studio/artworks/drafts',
'/studio/artworks/archived',
];
foreach ($routes as $route) {
$this->get($route)->assertRedirect('/login');
}
});
test('studio dashboard loads for authenticated user', function () {
$this->get('/studio')
->assertStatus(200);
});
test('studio artworks page loads', function () {
$this->get('/studio/artworks')
->assertStatus(200);
});
test('studio drafts page loads', function () {
$this->get('/studio/artworks/drafts')
->assertStatus(200);
});
test('studio archived page loads', function () {
$this->get('/studio/artworks/archived')
->assertStatus(200);
});
// ── API Tests ─────────────────────────────────────────────────────────────────
test('studio api requires authentication', function () {
auth()->logout();
$this->getJson('/api/studio/artworks')
->assertStatus(401);
});
test('studio api returns artworks for authenticated user', function () {
// Create artworks for this user
$artwork = studioArtwork([
'user_id' => $this->user->id,
'is_public' => true,
'is_approved' => true,
]);
ArtworkStats::create([
'artwork_id' => $artwork->id,
'views' => 100,
'downloads' => 10,
'favorites' => 5,
]);
$this->getJson('/api/studio/artworks')
->assertStatus(200)
->assertJsonStructure([
'data' => [['id', 'title', 'slug', 'views', 'favourites']],
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
]);
});
test('studio api does not return other users artworks', function () {
$otherUser = User::factory()->create();
studioArtwork([
'user_id' => $otherUser->id,
'is_public' => true,
'is_approved' => true,
]);
$this->getJson('/api/studio/artworks')
->assertStatus(200)
->assertJsonCount(0, 'data');
});
// ── Bulk Action Tests ─────────────────────────────────────────────────────────
test('bulk archive works on owned artworks', function () {
$artwork = studioArtwork([
'user_id' => $this->user->id,
'is_public' => true,
]);
$this->postJson('/api/studio/artworks/bulk', [
'action' => 'archive',
'artwork_ids' => [$artwork->id],
])
->assertStatus(200)
->assertJsonPath('success', 1);
expect($artwork->fresh()->trashed())->toBeTrue();
});
test('bulk delete requires confirmation', function () {
$artwork = studioArtwork(['user_id' => $this->user->id]);
$this->postJson('/api/studio/artworks/bulk', [
'action' => 'delete',
'artwork_ids' => [$artwork->id],
])
->assertStatus(422);
});
test('bulk delete with confirmation works', function () {
$artwork = studioArtwork(['user_id' => $this->user->id]);
$this->postJson('/api/studio/artworks/bulk', [
'action' => 'delete',
'artwork_ids' => [$artwork->id],
'confirm' => 'DELETE',
])
->assertStatus(200)
->assertJsonPath('success', 1);
});
test('bulk publish on owned artworks', function () {
$artwork = studioArtwork([
'user_id' => $this->user->id,
'is_public' => false,
]);
$this->postJson('/api/studio/artworks/bulk', [
'action' => 'publish',
'artwork_ids' => [$artwork->id],
])
->assertStatus(200)
->assertJsonPath('success', 1);
expect($artwork->fresh()->is_public)->toBeTrue();
});
test('bulk action cannot modify other users artworks', function () {
$otherUser = User::factory()->create();
$artwork = studioArtwork(['user_id' => $otherUser->id]);
$this->postJson('/api/studio/artworks/bulk', [
'action' => 'archive',
'artwork_ids' => [$artwork->id],
])
->assertStatus(422)
->assertJsonPath('success', 0)
->assertJsonPath('failed', 1);
});
// ── Toggle Tests ──────────────────────────────────────────────────────────────
test('toggle publish on single artwork', function () {
$artwork = studioArtwork([
'user_id' => $this->user->id,
'is_public' => false,
]);
$this->postJson("/api/studio/artworks/{$artwork->id}/toggle", [
'action' => 'publish',
])
->assertStatus(200)
->assertJsonPath('success', true);
expect($artwork->fresh()->is_public)->toBeTrue();
});
test('toggle on non-owned artwork returns 404', function () {
$otherUser = User::factory()->create();
$artwork = studioArtwork(['user_id' => $otherUser->id]);
$this->postJson("/api/studio/artworks/{$artwork->id}/toggle", [
'action' => 'archive',
])
->assertStatus(404);
});
// ── Analytics API Tests ───────────────────────────────────────────────────────
test('analytics api returns artwork stats', function () {
$artwork = studioArtwork(['user_id' => $this->user->id]);
ArtworkStats::create([
'artwork_id' => $artwork->id,
'views' => 500,
'downloads' => 20,
'favorites' => 30,
'shares_count' => 10,
'comments_count' => 5,
'ranking_score' => 42.5,
'heat_score' => 8.3,
]);
$this->getJson("/api/studio/artworks/{$artwork->id}/analytics")
->assertStatus(200)
->assertJsonStructure([
'artwork' => ['id', 'title', 'slug'],
'analytics' => ['views', 'favourites', 'shares', 'comments', 'downloads', 'ranking_score', 'heat_score'],
]);
});
test('analytics api denies access to other users artwork', function () {
$otherUser = User::factory()->create();
$artwork = studioArtwork(['user_id' => $otherUser->id]);
$this->getJson("/api/studio/artworks/{$artwork->id}/analytics")
->assertStatus(404);
});

View File

@@ -15,14 +15,46 @@ export default defineConfig({
'resources/js/entry-masonry-gallery.jsx',
'resources/js/entry-pill-carousel.jsx',
'resources/js/upload.jsx',
'resources/js/studio.jsx',
'resources/js/Pages/ArtworkPage.jsx',
'resources/js/Pages/Home/HomePage.jsx',
'resources/js/Pages/Community/LatestCommentsPage.jsx',
'resources/js/Pages/Messages/Index.jsx',
],
refresh: true,
// Only watch Blade templates & routes for full-reload triggers
// (instead of `true` which watches the entire project tree)
refresh: [
'resources/views/**',
'routes/**',
],
}),
],
server: {
watch: {
// Exclude heavy dirs from the filesystem watcher to cut memory
ignored: [
'**/node_modules/**',
'**/vendor/**',
'**/storage/**',
'**/public/build/**',
'**/.git/**',
],
},
},
// Pre-bundle heavy deps so Vite doesn't re-analyse them on every HMR update
optimizeDeps: {
include: [
'react',
'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'@inertiajs/react',
'framer-motion',
],
},
test: {
environment: 'jsdom',
globals: true,