Studio: make grid checkbox rectangular and commit table changes

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

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
/**
* Handles artwork listing queries for Studio, using Meilisearch with DB fallback.
*/
final class StudioArtworkQueryService
{
/**
* List artworks for a creator with search, filter, and sort via Meilisearch.
*
* Supported $filters keys:
* q string free-text search
* status string published|draft|archived
* category string category slug
* tags array tag slugs
* date_from string Y-m-d
* date_to string Y-m-d
* performance string rising|top|low
* sort string created_at:desc (default), ranking_score:desc, heat_score:desc, etc.
*/
public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator
{
// Skip Meilisearch when driver is null (e.g. in tests)
$driver = config('scout.driver');
if (empty($driver) || $driver === 'null') {
return $this->listViaDatabase($userId, $filters, $perPage);
}
try {
return $this->listViaMeilisearch($userId, $filters, $perPage);
} catch (\Throwable $e) {
Log::warning('Studio: Meilisearch unavailable, falling back to DB', [
'error' => $e->getMessage(),
]);
return $this->listViaDatabase($userId, $filters, $perPage);
}
}
private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator
{
$q = $filters['q'] ?? '';
$filterParts = ["author_id = {$userId}"];
$sort = [];
// Status filter
$status = $filters['status'] ?? null;
if ($status === 'published') {
$filterParts[] = 'is_public = true AND is_approved = true';
} elseif ($status === 'draft') {
$filterParts[] = 'is_public = false';
}
// archived handled at DB level since Meili doesn't see soft-deleted
// Category filter
if (!empty($filters['category'])) {
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
}
// Tag filter
if (!empty($filters['tags'])) {
foreach ((array) $filters['tags'] as $tag) {
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
}
}
// Date range
if (!empty($filters['date_from'])) {
$filterParts[] = 'created_at >= "' . $filters['date_from'] . '"';
}
if (!empty($filters['date_to'])) {
$filterParts[] = 'created_at <= "' . $filters['date_to'] . '"';
}
// Performance quick filters
if (!empty($filters['performance'])) {
match ($filters['performance']) {
'rising' => $filterParts[] = 'heat_score > 5',
'top' => $filterParts[] = 'ranking_score > 50',
'low' => $filterParts[] = 'views < 10',
default => null,
};
}
// Sort
$sortParam = $filters['sort'] ?? 'created_at:desc';
$validSortFields = [
'created_at', 'ranking_score', 'heat_score',
'views', 'likes', 'shares_count',
'downloads', 'comments_count', 'favorites_count',
];
$parts = explode(':', $sortParam);
if (count($parts) === 2 && in_array($parts[0], $validSortFields, true)) {
$sort[] = $parts[0] . ':' . ($parts[1] === 'asc' ? 'asc' : 'desc');
}
$options = ['filter' => implode(' AND ', $filterParts)];
if ($sort !== []) {
$options['sort'] = $sort;
}
return Artwork::search($q ?: '')
->options($options)
->query(fn (Builder $query) => $query
->with(['stats', 'categories', 'tags'])
->withCount(['comments', 'downloads'])
)
->paginate($perPage);
}
private function listViaDatabase(int $userId, array $filters, int $perPage): LengthAwarePaginator
{
$query = Artwork::where('user_id', $userId)
->with(['stats', 'categories', 'tags'])
->withCount(['comments', 'downloads']);
$status = $filters['status'] ?? null;
if ($status === 'published') {
$query->where('is_public', true)->where('is_approved', true);
} elseif ($status === 'draft') {
$query->where('is_public', false);
} elseif ($status === 'archived') {
$query->onlyTrashed();
} else {
// Show all except archived by default
$query->whereNull('deleted_at');
}
// Free-text search
if (!empty($filters['q'])) {
$q = $filters['q'];
$query->where(function (Builder $w) use ($q) {
$w->where('title', 'LIKE', "%{$q}%")
->orWhereHas('tags', fn (Builder $t) => $t->where('slug', 'LIKE', "%{$q}%"));
});
}
// Category
if (!empty($filters['category'])) {
$query->whereHas('categories', fn (Builder $c) => $c->where('slug', $filters['category']));
}
// Tags
if (!empty($filters['tags'])) {
foreach ((array) $filters['tags'] as $tag) {
$query->whereHas('tags', fn (Builder $t) => $t->where('slug', $tag));
}
}
// Date range
if (!empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (!empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
// Performance
if (!empty($filters['performance'])) {
$query->whereHas('stats', function (Builder $s) use ($filters) {
match ($filters['performance']) {
'rising' => $s->where('heat_score', '>', 5),
'top' => $s->where('ranking_score', '>', 50),
'low' => $s->where('views', '<', 10),
default => null,
};
});
}
// Sort
$sortParam = $filters['sort'] ?? 'created_at:desc';
$parts = explode(':', $sortParam);
$sortField = $parts[0] ?? 'created_at';
$sortDir = ($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
$dbSortMap = [
'created_at' => 'artworks.created_at',
'ranking_score' => 'ranking_score',
'heat_score' => 'heat_score',
'views' => 'views',
'likes' => 'favorites',
'shares_count' => 'shares_count',
'downloads' => 'downloads',
'comments_count' => 'comments_count',
'favorites_count' => 'favorites',
];
$statsSortFields = ['ranking_score', 'heat_score', 'views', 'likes', 'shares_count', 'downloads', 'comments_count', 'favorites_count'];
if (in_array($sortField, $statsSortFields, true)) {
$dbCol = $dbSortMap[$sortField] ?? $sortField;
$query->leftJoin('artwork_stats', 'artworks.id', '=', 'artwork_stats.artwork_id')
->orderBy("artwork_stats.{$dbCol}", $sortDir)
->select('artworks.*');
} else {
$query->orderBy($dbSortMap[$sortField] ?? 'artworks.created_at', $sortDir);
}
return $query->paginate($perPage);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\Tag;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Handles bulk operations on artworks for the Studio module.
*/
final class StudioBulkActionService
{
/**
* Execute a bulk action on the given artwork IDs, enforcing ownership.
*
* @param int $userId The authenticated user ID
* @param string $action publish|unpublish|archive|unarchive|delete|change_category|add_tags|remove_tags
* @param array $artworkIds Array of artwork IDs
* @param array $params Extra params (category_id, tag_ids)
* @return array{success: int, failed: int, errors: array}
*/
public function execute(int $userId, string $action, array $artworkIds, array $params = []): array
{
$result = ['success' => 0, 'failed' => 0, 'errors' => []];
// Validate ownership — fetch only artworks belonging to this user
$query = Artwork::where('user_id', $userId);
if ($action === 'unarchive') {
$query->onlyTrashed();
}
$artworks = $query->whereIn('id', $artworkIds)->get();
$foundIds = $artworks->pluck('id')->all();
$missingIds = array_diff($artworkIds, $foundIds);
foreach ($missingIds as $id) {
$result['failed']++;
$result['errors'][] = "Artwork #{$id}: not found or not owned by you";
}
if ($artworks->isEmpty()) {
return $result;
}
DB::beginTransaction();
try {
foreach ($artworks as $artwork) {
$this->applyAction($artwork, $action, $params);
$result['success']++;
}
DB::commit();
// Reindex affected artworks in Meilisearch
$this->reindexArtworks($artworks);
Log::info('Studio bulk action completed', [
'user_id' => $userId,
'action' => $action,
'count' => $result['success'],
'ids' => $foundIds,
]);
} catch (\Throwable $e) {
DB::rollBack();
$result['failed'] += $result['success'];
$result['success'] = 0;
$result['errors'][] = 'Transaction failed: ' . $e->getMessage();
Log::error('Studio bulk action failed', [
'user_id' => $userId,
'action' => $action,
'error' => $e->getMessage(),
]);
}
return $result;
}
private function applyAction(Artwork $artwork, string $action, array $params): void
{
match ($action) {
'publish' => $this->publish($artwork),
'unpublish' => $this->unpublish($artwork),
'archive' => $artwork->delete(), // Soft delete
'unarchive' => $artwork->restore(),
'delete' => $artwork->forceDelete(),
'change_category' => $this->changeCategory($artwork, $params),
'add_tags' => $this->addTags($artwork, $params),
'remove_tags' => $this->removeTags($artwork, $params),
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
};
}
private function publish(Artwork $artwork): void
{
$artwork->update([
'is_public' => true,
'published_at' => $artwork->published_at ?? now(),
]);
}
private function unpublish(Artwork $artwork): void
{
$artwork->update(['is_public' => false]);
}
private function changeCategory(Artwork $artwork, array $params): void
{
if (empty($params['category_id'])) {
throw new \InvalidArgumentException('category_id required for change_category');
}
$artwork->categories()->sync([(int) $params['category_id']]);
}
private function addTags(Artwork $artwork, array $params): void
{
if (empty($params['tag_ids'])) {
throw new \InvalidArgumentException('tag_ids required for add_tags');
}
$pivotData = [];
foreach ((array) $params['tag_ids'] as $tagId) {
$pivotData[(int) $tagId] = ['source' => 'studio_bulk', 'confidence' => 1.0];
}
$artwork->tags()->syncWithoutDetaching($pivotData);
// Increment usage counts
Tag::whereIn('id', array_keys($pivotData))
->increment('usage_count');
}
private function removeTags(Artwork $artwork, array $params): void
{
if (empty($params['tag_ids'])) {
throw new \InvalidArgumentException('tag_ids required for remove_tags');
}
$tagIds = array_map('intval', (array) $params['tag_ids']);
$artwork->tags()->detach($tagIds);
Tag::whereIn('id', $tagIds)
->where('usage_count', '>', 0)
->decrement('usage_count');
}
/**
* Trigger Meilisearch reindex for the given artworks.
*/
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
{
try {
$artworks->each->searchable();
} catch (\Throwable $e) {
Log::warning('Studio: Failed to reindex artworks after bulk action', [
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Provides dashboard KPI data for the Studio overview page.
*/
final class StudioMetricsService
{
private const CACHE_TTL = 300; // 5 minutes
/**
* Get dashboard KPI metrics for a creator.
*
* @return array{total_artworks: int, views_30d: int, favourites_30d: int, shares_30d: int, followers: int}
*/
public function getDashboardKpis(int $userId): array
{
$cacheKey = "studio.kpi.{$userId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
$totalArtworks = Artwork::where('user_id', $userId)
->whereNull('deleted_at')
->count();
// Aggregate stats from artwork_stats for this user's artworks
$statsAgg = DB::table('artwork_stats')
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->selectRaw('
COALESCE(SUM(artwork_stats.views), 0) as total_views,
COALESCE(SUM(artwork_stats.favorites), 0) as total_favourites,
COALESCE(SUM(artwork_stats.shares_count), 0) as total_shares
')
->first();
// Views in last 30 days from hourly snapshots if available, fallback to totals
$views30d = 0;
try {
if (\Illuminate\Support\Facades\Schema::hasTable('artwork_metric_snapshots_hourly')) {
$views30d = (int) DB::table('artwork_metric_snapshots_hourly')
->join('artworks', 'artworks.id', '=', 'artwork_metric_snapshots_hourly.artwork_id')
->where('artworks.user_id', $userId)
->where('artwork_metric_snapshots_hourly.bucket_hour', '>=', now()->subDays(30))
->sum('artwork_metric_snapshots_hourly.views_count');
}
} catch (\Throwable $e) {
// Table or column doesn't exist — fall back to totals
}
if ($views30d === 0) {
$views30d = (int) ($statsAgg->total_views ?? 0);
}
$followers = DB::table('user_followers')
->where('user_id', $userId)
->count();
return [
'total_artworks' => $totalArtworks,
'views_30d' => $views30d,
'favourites_30d' => (int) ($statsAgg->total_favourites ?? 0),
'shares_30d' => (int) ($statsAgg->total_shares ?? 0),
'followers' => $followers,
];
});
}
/**
* Get top performing artworks for a creator in the last 7 days.
*
* @return \Illuminate\Support\Collection
*/
public function getTopPerformers(int $userId, int $limit = 6): \Illuminate\Support\Collection
{
$cacheKey = "studio.top_performers.{$userId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId, $limit) {
return Artwork::where('user_id', $userId)
->whereNull('deleted_at')
->where('is_public', true)
->with(['stats', 'tags'])
->whereHas('stats')
->orderByDesc(
ArtworkStats::select('heat_score')
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
->limit(1)
)
->limit($limit)
->get()
->map(fn (Artwork $art) => [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug,
'thumb_url' => $art->thumbUrl('md'),
'favourites' => (int) ($art->stats?->favorites ?? 0),
'shares' => (int) ($art->stats?->shares_count ?? 0),
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
]);
});
}
/**
* Get recent comments on a creator's artworks.
*
* @return \Illuminate\Support\Collection
*/
public function getRecentComments(int $userId, int $limit = 5): \Illuminate\Support\Collection
{
return DB::table('artwork_comments')
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
->join('users', 'users.id', '=', 'artwork_comments.user_id')
->where('artworks.user_id', $userId)
->whereNull('artwork_comments.deleted_at')
->orderByDesc('artwork_comments.created_at')
->limit($limit)
->select([
'artwork_comments.id',
'artwork_comments.content as body',
'artwork_comments.created_at',
'users.name as author_name',
'users.username as author_username',
'artworks.title as artwork_title',
'artworks.slug as artwork_slug',
])
->get();
}
/**
* Aggregate analytics across all artworks for the Studio Analytics page.
*
* @return array{totals: array, top_artworks: array, content_breakdown: array}
*/
public function getAnalyticsOverview(int $userId): array
{
$cacheKey = "studio.analytics_overview.{$userId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
// Totals
$totals = DB::table('artwork_stats')
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->selectRaw('
COALESCE(SUM(artwork_stats.views), 0) as views,
COALESCE(SUM(artwork_stats.favorites), 0) as favourites,
COALESCE(SUM(artwork_stats.shares_count), 0) as shares,
COALESCE(SUM(artwork_stats.downloads), 0) as downloads,
COALESCE(SUM(artwork_stats.comments_count), 0) as comments,
COALESCE(AVG(artwork_stats.ranking_score), 0) as avg_ranking,
COALESCE(AVG(artwork_stats.heat_score), 0) as avg_heat
')
->first();
// Top 10 artworks by ranking score
$topArtworks = Artwork::where('user_id', $userId)
->whereNull('deleted_at')
->where('is_public', true)
->with(['stats'])
->whereHas('stats')
->orderByDesc(
ArtworkStats::select('ranking_score')
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
->limit(1)
)
->limit(10)
->get()
->map(fn (Artwork $art) => [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug,
'thumb_url' => $art->thumbUrl('sq'),
'views' => (int) ($art->stats?->views ?? 0),
'favourites' => (int) ($art->stats?->favorites ?? 0),
'shares' => (int) ($art->stats?->shares_count ?? 0),
'downloads' => (int) ($art->stats?->downloads ?? 0),
'comments' => (int) ($art->stats?->comments_count ?? 0),
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
]);
// Content type breakdown
$contentBreakdown = DB::table('artworks')
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
->join('content_types', 'content_types.id', '=', 'categories.content_type_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->groupBy('content_types.id', 'content_types.name', 'content_types.slug')
->select([
'content_types.name',
'content_types.slug',
DB::raw('COUNT(DISTINCT artworks.id) as count'),
])
->orderByDesc('count')
->get()
->map(fn ($row) => [
'name' => $row->name,
'slug' => $row->slug,
'count' => (int) $row->count,
])
->values()
->all();
return [
'totals' => [
'views' => (int) ($totals->views ?? 0),
'favourites' => (int) ($totals->favourites ?? 0),
'shares' => (int) ($totals->shares ?? 0),
'downloads' => (int) ($totals->downloads ?? 0),
'comments' => (int) ($totals->comments ?? 0),
'avg_ranking' => round((float) ($totals->avg_ranking ?? 0), 1),
'avg_heat' => round((float) ($totals->avg_heat ?? 0), 1),
],
'top_artworks' => $topArtworks->values()->all(),
'content_breakdown' => $contentBreakdown,
];
});
}
}