Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
166
app/Console/Commands/RecalculateHeatCommand.php
Normal 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 10–15 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,29 +7,33 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/art/{id}/similar
|
||||
*
|
||||
* Returns up to 12 similar artworks based on:
|
||||
* 1. Tag overlap (primary signal)
|
||||
* 2. Same category
|
||||
* 3. Similar orientation
|
||||
* Returns up to 12 similar artworks using the hybrid recommender (precomputed lists)
|
||||
* with a Meilisearch-based fallback if no precomputed data exists.
|
||||
*
|
||||
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
|
||||
* Current artwork and its creator are excluded from results.
|
||||
* Query params:
|
||||
* ?type=similar (default) | visual | tags | behavior
|
||||
*
|
||||
* Priority (default):
|
||||
* 1. Hybrid precomputed (tag + behavior + optional vector)
|
||||
* 2. Meilisearch tag-overlap fallback (legacy)
|
||||
*/
|
||||
final class SimilarArtworksController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
/** Spec §5: cache similar artworks 30–60 min; using config with 30 min default. */
|
||||
private const CACHE_TTL = 1800; // 30 minutes
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
private function findSimilar(Artwork $artwork): array
|
||||
// Fall back to Meilisearch tag-overlap search
|
||||
$items = $this->findSimilarViaSearch($artwork);
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
private function formatArtwork(Artwork $artwork): array
|
||||
{
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb' => $artwork->thumbUrl('md'),
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'author' => $artwork->user?->name ?? 'Artist',
|
||||
'author_avatar' => $artwork->user?->profile?->avatar_url,
|
||||
'author_id' => $artwork->user_id,
|
||||
'orientation' => $this->orientation($artwork),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Meilisearch-based similar artworks (fallback).
|
||||
*/
|
||||
private function findSimilarViaSearch(Artwork $artwork): array
|
||||
{
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||
$srcOrientation = $this->orientation($artwork);
|
||||
|
||||
// Build Meilisearch filter: exclude self and same creator
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
@@ -63,7 +109,6 @@ final class SimilarArtworksController extends Controller
|
||||
'author_id != ' . $artwork->user_id,
|
||||
];
|
||||
|
||||
// Priority 1: tag overlap (OR match across tags)
|
||||
if ($tagSlugs !== []) {
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
@@ -71,7 +116,6 @@ final class SimilarArtworksController extends Controller
|
||||
));
|
||||
$filterParts[] = '(' . $tagFilter . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
// Fallback to category if no tags
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
$categorySlugs
|
||||
@@ -79,7 +123,6 @@ final class SimilarArtworksController extends Controller
|
||||
$filterParts[] = '(' . $catFilter . ')';
|
||||
}
|
||||
|
||||
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
@@ -90,9 +133,6 @@ final class SimilarArtworksController extends Controller
|
||||
$collection = $results->getCollection();
|
||||
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
// ── PHP reranking ──────────────────────────────────────────────────────
|
||||
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
||||
// +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10
|
||||
$srcTagSet = array_flip($tagSlugs);
|
||||
$srcW = (int) ($artwork->width ?? 0);
|
||||
$srcH = (int) ($artwork->height ?? 0);
|
||||
@@ -103,15 +143,12 @@ final class SimilarArtworksController extends Controller
|
||||
$cTagSlugs = $candidate->tags->pluck('slug')->all();
|
||||
$cTagSet = array_flip($cTagSlugs);
|
||||
|
||||
// Tag overlap (Sørensen–Dice-like)
|
||||
$common = count(array_intersect_key($srcTagSet, $cTagSet));
|
||||
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
|
||||
$tagOverlap = $common / $total;
|
||||
|
||||
// Orientation bonus
|
||||
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
|
||||
|
||||
// Resolution proximity bonus (both axes within 25 %)
|
||||
$cW = (int) ($candidate->width ?? 0);
|
||||
$cH = (int) ($candidate->height ?? 0);
|
||||
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
|
||||
@@ -119,11 +156,9 @@ final class SimilarArtworksController extends Controller
|
||||
&& abs($cH - $srcH) / $srcH <= 0.25
|
||||
) ? 0.05 : 0.0;
|
||||
|
||||
// Popularity boost (log-normalised views, capped at 0.15)
|
||||
$views = max(0, (int) ($candidate->stats?->views ?? 0));
|
||||
$popularity = min(0.15, log(1 + $views) / 13.0);
|
||||
|
||||
// Freshness boost (exp decay, 60-day half-life, weight 0.10)
|
||||
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$freshness = exp(-$ageDays / 60.0) * 0.10;
|
||||
@@ -140,20 +175,10 @@ final class SimilarArtworksController extends Controller
|
||||
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
return array_values(
|
||||
array_map(fn (array $item): array => [
|
||||
'id' => $item['artwork']->id,
|
||||
'title' => $item['artwork']->title,
|
||||
'slug' => $item['artwork']->slug,
|
||||
'thumb' => $item['artwork']->thumbUrl('md'),
|
||||
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
|
||||
'author' => $item['artwork']->user?->name ?? 'Artist',
|
||||
'author_avatar' => $item['artwork']->user?->profile?->avatar_url,
|
||||
'author_id' => $item['artwork']->user_id,
|
||||
'orientation' => $this->orientation($item['artwork']),
|
||||
'width' => $item['artwork']->width,
|
||||
'height' => $item['artwork']->height,
|
||||
'score' => round((float) $item['score'], 5),
|
||||
], array_slice($scored, 0, self::LIMIT))
|
||||
array_map(fn (array $item): array => array_merge(
|
||||
$this->formatArtwork($item['artwork']),
|
||||
['score' => round((float) $item['score'], 5)]
|
||||
), array_slice($scored, 0, self::LIMIT))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) [
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
349
app/Http/Controllers/Studio/StudioArtworksApiController.php
Normal file
349
app/Http/Controllers/Studio/StudioArtworksApiController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
174
app/Http/Controllers/Studio/StudioController.php
Normal file
174
app/Http/Controllers/Studio/StudioController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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) [
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal file
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal 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(n²) 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/Jobs/RecComputeSimilarByBehaviorJob.php
Normal file
129
app/Jobs/RecComputeSimilarByBehaviorJob.php
Normal 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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
225
app/Jobs/RecComputeSimilarByTagsJob.php
Normal file
225
app/Jobs/RecComputeSimilarByTagsJob.php
Normal 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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
286
app/Jobs/RecComputeSimilarHybridJob.php
Normal file
286
app/Jobs/RecComputeSimilarHybridJob.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
53
app/Models/ArtworkMetricSnapshotHourly.php
Normal file
53
app/Models/ArtworkMetricSnapshotHourly.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
48
app/Models/RecArtworkRec.php
Normal file
48
app/Models/RecArtworkRec.php
Normal 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
54
app/Models/RecEvent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
54
app/Models/RecItemPair.php
Normal file
54
app/Models/RecItemPair.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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`.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal file
180
app/Services/Recommendations/HybridSimilarArtworksService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
209
app/Services/Studio/StudioArtworkQueryService.php
Normal file
209
app/Services/Studio/StudioArtworkQueryService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
165
app/Services/Studio/StudioBulkActionService.php
Normal file
165
app/Services/Studio/StudioBulkActionService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
229
app/Services/Studio/StudioMetricsService.php
Normal file
229
app/Services/Studio/StudioMetricsService.php
Normal 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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
return [
|
||||
'uploads_v2' => (bool) env('SKINBASE_UPLOADS_V2', true),
|
||||
'similarity_vector' => (bool) env('SIMILARITY_VECTOR_ENABLED', false),
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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} />
|
||||
|
||||
85
resources/js/Pages/Home/HomeRising.jsx
Normal file
85
resources/js/Pages/Home/HomeRising.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
213
resources/js/Pages/Studio/StudioAnalytics.jsx
Normal file
213
resources/js/Pages/Studio/StudioAnalytics.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
resources/js/Pages/Studio/StudioArchived.jsx
Normal file
203
resources/js/Pages/Studio/StudioArchived.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
resources/js/Pages/Studio/StudioArtworkAnalytics.jsx
Normal file
128
resources/js/Pages/Studio/StudioArtworkAnalytics.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
455
resources/js/Pages/Studio/StudioArtworkEdit.jsx
Normal file
455
resources/js/Pages/Studio/StudioArtworkEdit.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
341
resources/js/Pages/Studio/StudioArtworks.jsx
Normal file
341
resources/js/Pages/Studio/StudioArtworks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
resources/js/Pages/Studio/StudioDashboard.jsx
Normal file
141
resources/js/Pages/Studio/StudioDashboard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
resources/js/Pages/Studio/StudioDrafts.jsx
Normal file
208
resources/js/Pages/Studio/StudioDrafts.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
resources/js/components/Badges/RisingBadge.jsx
Normal file
27
resources/js/components/Badges/RisingBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
resources/js/components/Badges/StatusBadge.jsx
Normal file
18
resources/js/components/Badges/StatusBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
resources/js/components/Studio/BulkActionsBar.jsx
Normal file
77
resources/js/components/Studio/BulkActionsBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
resources/js/components/Studio/BulkCategoryModal.jsx
Normal file
96
resources/js/components/Studio/BulkCategoryModal.jsx
Normal 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"> {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>
|
||||
)
|
||||
}
|
||||
208
resources/js/components/Studio/BulkTagModal.jsx
Normal file
208
resources/js/components/Studio/BulkTagModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/Studio/ConfirmDangerModal.jsx
Normal file
76
resources/js/components/Studio/ConfirmDangerModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
resources/js/components/Studio/StudioFilters.jsx
Normal file
127
resources/js/components/Studio/StudioFilters.jsx
Normal 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"> {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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
101
resources/js/components/Studio/StudioGridCard.jsx
Normal file
101
resources/js/components/Studio/StudioGridCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
resources/js/components/Studio/StudioTable.jsx
Normal file
144
resources/js/components/Studio/StudioTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
resources/js/components/Studio/StudioToolbar.jsx
Normal file
90
resources/js/components/Studio/StudioToolbar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
31
resources/js/studio.jsx
Normal 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} />)
|
||||
},
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
18
resources/views/studio.blade.php
Normal file
18
resources/views/studio.blade.php
Normal 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
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
42
tests/Feature/DiscoverRisingTest.php
Normal file
42
tests/Feature/DiscoverRisingTest.php
Normal 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);
|
||||
});
|
||||
430
tests/Feature/Recommendations/SimilarArtworksHybridTest.php
Normal file
430
tests/Feature/Recommendations/SimilarArtworksHybridTest.php
Normal 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);
|
||||
});
|
||||
278
tests/Feature/RisingEngineTest.php
Normal file
278
tests/Feature/RisingEngineTest.php
Normal 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);
|
||||
});
|
||||
236
tests/Feature/StudioTest.php
Normal file
236
tests/Feature/StudioTest.php
Normal 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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user