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,349 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
/**
* JSON API endpoints for the Studio artwork manager.
*/
final class StudioArtworksApiController extends Controller
{
public function __construct(
private readonly StudioArtworkQueryService $queryService,
private readonly StudioBulkActionService $bulkService,
) {}
/**
* GET /api/studio/artworks
* List artworks with search, filter, sort, pagination.
*/
public function index(Request $request): JsonResponse
{
$userId = $request->user()->id;
$filters = $request->only([
'q', 'status', 'category', 'tags', 'date_from', 'date_to',
'performance', 'sort',
]);
$perPage = (int) $request->get('per_page', 24);
$perPage = min(max($perPage, 12), 100);
$paginator = $this->queryService->list($userId, $filters, $perPage);
// Transform the paginator items to a clean DTO
$items = collect($paginator->items())->map(fn ($artwork) => $this->transformArtwork($artwork));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
/**
* POST /api/studio/artworks/bulk
* Execute bulk operations.
*/
public function bulk(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
'artwork_ids' => 'required|array|min:1|max:200',
'artwork_ids.*' => 'integer',
'params' => 'sometimes|array',
'params.category_id' => 'sometimes|integer|exists:categories,id',
'params.tag_ids' => 'sometimes|array',
'params.tag_ids.*' => 'integer|exists:tags,id',
'confirm' => 'required_if:action,delete|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$data = $validator->validated();
// Require explicit DELETE confirmation
if ($data['action'] === 'delete' && ($data['confirm'] ?? '') !== 'DELETE') {
return response()->json([
'errors' => ['confirm' => ['You must type DELETE to confirm permanent deletion.']],
], 422);
}
$result = $this->bulkService->execute(
$request->user()->id,
$data['action'],
$data['artwork_ids'],
$data['params'] ?? [],
);
$statusCode = $result['failed'] > 0 && $result['success'] === 0 ? 422 : 200;
return response()->json($result, $statusCode);
}
/**
* PUT /api/studio/artworks/{id}
* Update artwork details (title, description, visibility).
*/
public function update(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'is_public' => 'sometimes|boolean',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'tags' => 'sometimes|array|max:15',
'tags.*' => 'string|max:64',
]);
if (isset($validated['is_public'])) {
if ($validated['is_public'] && !$artwork->is_public) {
$validated['published_at'] = $artwork->published_at ?? now();
}
}
// Extract tags and category before updating core fields
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
unset($validated['tags'], $validated['category_id']);
$artwork->update($validated);
// Sync category
if ($categoryId !== null) {
$artwork->categories()->sync([(int) $categoryId]);
}
// Sync tags (by slug/name)
if ($tags !== null) {
$tagIds = [];
foreach ($tags as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate(
['slug' => \Illuminate\Support\Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
);
$tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0];
}
$artwork->tags()->sync($tagIds);
}
// Reindex in Meilisearch
try {
$artwork->searchable();
} catch (\Throwable $e) {
// Meilisearch may be unavailable
}
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags']);
$primaryCategory = $artwork->categories->first();
return response()->json([
'success' => true,
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'slug' => $artwork->slug,
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
],
]);
}
/**
* POST /api/studio/artworks/{id}/toggle
* Toggle publish/unpublish/archive for a single artwork.
*/
public function toggle(Request $request, int $id): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$result = $this->bulkService->execute(
$request->user()->id,
$validator->validated()['action'],
[$id],
);
if ($result['success'] === 0) {
return response()->json(['error' => 'Action failed', 'details' => $result['errors']], 404);
}
return response()->json(['success' => true]);
}
/**
* GET /api/studio/artworks/{id}/analytics
* Analytics data for a single artwork.
*/
public function analytics(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()
->with(['stats', 'awardStat'])
->findOrFail($id);
$stats = $artwork->stats;
return response()->json([
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
}
private function transformArtwork($artwork): array
{
$stats = $artwork->stats ?? null;
return [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->categories->first()?->name,
'category_slug' => $artwork->categories->first()?->slug,
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
];
}
/**
* GET /api/studio/tags/search?q=...
* Search active tags by name for the bulk tag picker.
*/
public function searchTags(Request $request): JsonResponse
{
$query = trim((string) $request->input('q'));
$tags = \App\Models\Tag::query()
->where('is_active', true)
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
->orderByDesc('usage_count')
->limit(30)
->get(['id', 'name', 'slug', 'usage_count']);
return response()->json($tags);
}
/**
* POST /api/studio/artworks/{id}/replace-file
* Replace the artwork's primary image file and regenerate derivatives.
*/
public function replaceFile(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$request->validate([
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50MB
]);
$file = $request->file('file');
$tempPath = $file->getRealPath();
// Compute SHA-256 hash
$hash = hash_file('sha256', $tempPath);
try {
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
// Store original
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
$originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp');
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
// Generate public derivatives
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAbsolute as $variant => $absolutePath) {
$filename = $variant . '.webp';
$relativePath = $storage->publicRelativePath($hash, $filename);
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
}
// Get dimensions
$dimensions = @getimagesize($tempPath);
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width;
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height;
// Update artwork record
$artwork->update([
'file_name' => 'orig.webp',
'file_path' => '',
'file_size' => (int) filesize($originalPath),
'mime_type' => 'image/webp',
'hash' => $hash,
'file_ext' => 'webp',
'thumb_ext' => 'webp',
'width' => max(1, $width),
'height' => max(1, $height),
]);
// Reindex
try {
$artwork->searchable();
} catch (\Throwable) {}
return response()->json([
'success' => true,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'width' => $artwork->width,
'height' => $artwork->height,
'file_size' => $artwork->file_size,
]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'error' => 'File processing failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\Studio\StudioMetricsService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* Serves Studio Inertia pages for authenticated creators.
*/
final class StudioController extends Controller
{
public function __construct(
private readonly StudioMetricsService $metrics,
) {}
/**
* Studio Overview Dashboard (/studio)
*/
public function index(Request $request): Response
{
$userId = $request->user()->id;
return Inertia::render('Studio/StudioDashboard', [
'kpis' => $this->metrics->getDashboardKpis($userId),
'topPerformers' => $this->metrics->getTopPerformers($userId, 6),
'recentComments' => $this->metrics->getRecentComments($userId, 5),
]);
}
/**
* Artwork Manager (/studio/artworks)
*/
public function artworks(Request $request): Response
{
return Inertia::render('Studio/StudioArtworks', [
'categories' => $this->getCategories(),
]);
}
/**
* Drafts (/studio/artworks/drafts)
*/
public function drafts(Request $request): Response
{
return Inertia::render('Studio/StudioDrafts', [
'categories' => $this->getCategories(),
]);
}
/**
* Archived (/studio/artworks/archived)
*/
public function archived(Request $request): Response
{
return Inertia::render('Studio/StudioArchived', [
'categories' => $this->getCategories(),
]);
}
/**
* Edit artwork (/studio/artworks/:id/edit)
*/
public function edit(Request $request, int $id): Response
{
$artwork = $request->user()->artworks()
->with(['stats', 'categories.contentType', 'tags'])
->findOrFail($id);
$primaryCategory = $artwork->categories->first();
return Inertia::render('Studio/StudioArtworkEdit', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'file_name' => $artwork->file_name,
'file_size' => $artwork->file_size,
'width' => $artwork->width,
'height' => $artwork->height,
'mime_type' => $artwork->mime_type,
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
],
'contentTypes' => $this->getCategories(),
]);
}
/**
* Analytics v1 (/studio/artworks/:id/analytics)
*/
public function analytics(Request $request, int $id): Response
{
$artwork = $request->user()->artworks()
->with(['stats', 'awardStat'])
->findOrFail($id);
$stats = $artwork->stats;
return Inertia::render('Studio/StudioArtworkAnalytics', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md'),
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
}
/**
* Studio-wide Analytics (/studio/analytics)
*/
public function analyticsOverview(Request $request): Response
{
$userId = $request->user()->id;
$data = $this->metrics->getAnalyticsOverview($userId);
return Inertia::render('Studio/StudioAnalytics', [
'totals' => $data['totals'],
'topArtworks' => $data['top_artworks'],
'contentBreakdown' => $data['content_breakdown'],
'recentComments' => $this->metrics->getRecentComments($userId, 8),
]);
}
private function getCategories(): array
{
return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) {
return [
'id' => $ct->id,
'name' => $ct->name,
'slug' => $ct->slug,
'categories' => $ct->rootCategories->map(function ($c) {
return [
'id' => $c->id,
'name' => $c->name,
'slug' => $c->slug,
'children' => $c->children->map(fn ($ch) => [
'id' => $ch->id,
'name' => $ch->name,
'slug' => $ch->slug,
])->values()->all(),
];
})->values()->all(),
];
})->values()->all();
}
}