optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Http\Requests\Studio\ApplyArtworkAiAssistRequest;
use App\Services\Studio\StudioAiAssistEventService;
use App\Services\Studio\StudioAiAssistService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StudioArtworkAiAssistApiController extends Controller
{
public function __construct(
private readonly StudioAiAssistService $aiAssist,
private readonly StudioAiAssistEventService $eventService,
) {
}
public function show(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType', 'artworkAiAssist'])->findOrFail($id);
return response()->json([
'data' => $this->aiAssist->payloadFor($artwork),
]);
}
public function analyze(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
$direct = (bool) $request->boolean('direct');
$intent = $request->validate([
'direct' => ['sometimes', 'boolean'],
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
])['intent'] ?? null;
if ($direct) {
$assist = $this->aiAssist->analyzeDirect($artwork, false, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => true,
'data' => $this->aiAssist->payloadFor($artwork->fresh(['tags', 'categories.contentType', 'artworkAiAssist'])),
]);
}
$assist = $this->aiAssist->queueAnalysis($artwork, false, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => false,
], 202);
}
public function regenerate(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType'])->findOrFail($id);
$direct = (bool) $request->boolean('direct');
$intent = $request->validate([
'direct' => ['sometimes', 'boolean'],
'intent' => ['sometimes', 'nullable', 'string', 'in:analyze,title,description,tags,category,similar'],
])['intent'] ?? null;
if ($direct) {
$assist = $this->aiAssist->analyzeDirect($artwork, true, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => true,
'data' => $this->aiAssist->payloadFor($artwork->fresh(['tags', 'categories.contentType', 'artworkAiAssist'])),
]);
}
$assist = $this->aiAssist->queueAnalysis($artwork, true, $intent);
return response()->json([
'success' => true,
'status' => $assist->status,
'direct' => false,
], 202);
}
public function apply(ApplyArtworkAiAssistRequest $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->with(['tags', 'categories.contentType', 'artworkAiAssist'])->findOrFail($id);
return response()->json([
'success' => true,
'data' => $this->aiAssist->applySuggestions($artwork, $request->validated()),
]);
}
public function event(Request $request, int $id): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', 'max:64'],
'meta' => ['sometimes', 'array'],
]);
$artwork = $request->user()->artworks()->with('artworkAiAssist')->findOrFail($id);
$this->eventService->record(
$artwork,
(string) $payload['event_type'],
(array) ($payload['meta'] ?? []),
$artwork->artworkAiAssist,
);
return response()->json(['success' => true], 201);
}
}

View File

@@ -5,17 +5,23 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkVersion;
use App\Services\ArtworkSearchIndexer;
use App\Services\TagService;
use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Services\Tags\TagDiscoveryService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
/**
@@ -29,6 +35,7 @@ final class StudioArtworksApiController extends Controller
private readonly ArtworkVersioningService $versioningService,
private readonly ArtworkSearchIndexer $searchIndexer,
private readonly TagDiscoveryService $tagDiscoveryService,
private readonly TagService $tagService,
) {}
/**
@@ -117,21 +124,74 @@ final class StudioArtworksApiController extends Controller
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'is_public' => 'sometimes|boolean',
'visibility' => 'sometimes|string|in:public,unlisted,private',
'mode' => 'sometimes|string|in:now,schedule',
'publish_at' => 'sometimes|nullable|string|date',
'timezone' => 'sometimes|nullable|string|max:64',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
'tags' => 'sometimes|array|max:15',
'tags.*' => 'string|max:64',
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
'category_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
]);
if (isset($validated['is_public'])) {
if ($validated['is_public'] && !$artwork->is_public) {
$validated['published_at'] = $artwork->published_at ?? now();
$visibility = (string) ($validated['visibility'] ?? ($artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE)));
$mode = (string) ($validated['mode'] ?? ($artwork->artwork_status === 'scheduled' ? 'schedule' : 'now'));
$timezone = array_key_exists('timezone', $validated)
? $validated['timezone']
: $artwork->artwork_timezone;
$publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try {
$publishAt = Carbon::parse($validated['publish_at'])->utc();
} catch (\Throwable) {
return response()->json(['errors' => ['publish_at' => ['Invalid publish date/time.']]], 422);
}
if ($publishAt->lte(now()->addMinute())) {
return response()->json(['errors' => ['publish_at' => ['Scheduled publish time must be at least 1 minute in the future.']]], 422);
}
} elseif ($mode === 'schedule') {
return response()->json(['errors' => ['publish_at' => ['Choose a date and time for scheduled publishing.']]], 422);
}
// Extract tags and category before updating core fields
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
unset($validated['tags'], $validated['category_id']);
$contentTypeId = $validated['content_type_id'] ?? null;
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone']);
$validated['visibility'] = $visibility;
$validated['artwork_timezone'] = $timezone;
if ($mode === 'schedule' && $publishAt) {
$validated['is_public'] = false;
$validated['is_approved'] = true;
$validated['publish_at'] = $publishAt;
$validated['published_at'] = null;
$validated['artwork_status'] = 'scheduled';
} else {
$validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE;
$validated['is_approved'] = true;
$validated['publish_at'] = null;
$validated['artwork_status'] = 'published';
if (($validated['is_public'] ?? false) && ! $artwork->published_at) {
$validated['published_at'] = now();
}
if ($visibility === Artwork::VISIBILITY_PRIVATE) {
$validated['published_at'] = $artwork->published_at;
}
}
if ($categoryId === null && $contentTypeId !== null) {
$categoryId = $this->resolveCategoryIdForContentType((int) $contentTypeId);
}
$artwork->update($validated);
@@ -140,22 +200,26 @@ final class StudioArtworksApiController extends Controller
$artwork->categories()->sync([(int) $categoryId]);
}
// Sync tags (by slug/name)
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
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]
try {
$this->tagService->syncStudioTags(
$artwork,
$tags,
(string) ($validated['tags_source'] ?? 'manual')
);
$tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0];
} catch (ValidationException $exception) {
return response()->json(['errors' => $exception->errors()], 422);
}
$artwork->tags()->sync($tagIds);
}
// Reindex in Meilisearch
try {
$artwork->searchable();
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
// Meilisearch may be unavailable
}
@@ -171,14 +235,49 @@ final class StudioArtworksApiController extends Controller
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'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(),
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
],
]);
}
private function resolveCategoryIdForContentType(int $contentTypeId): ?int
{
$contentType = ContentType::query()->find($contentTypeId);
if (! $contentType) {
return null;
}
$category = $contentType->rootCategories()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->first();
if (! $category) {
$category = Category::query()
->where('content_type_id', $contentType->id)
->where('is_active', true)
->orderByRaw('CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END')
->orderBy('sort_order')
->orderBy('name')
->first();
}
return $category?->id;
}
/**
* POST /api/studio/artworks/{id}/toggle
* Toggle publish/unpublish/archive for a single artwork.
@@ -247,8 +346,11 @@ final class StudioArtworksApiController extends Controller
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->categories->first()?->name,

View File

@@ -71,7 +71,7 @@ final class StudioController extends Controller
public function edit(Request $request, int $id): Response
{
$artwork = $request->user()->artworks()
->with(['stats', 'categories.contentType', 'tags'])
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist'])
->findOrFail($id);
$primaryCategory = $artwork->categories->first();
@@ -83,7 +83,12 @@ final class StudioController extends Controller
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
'is_approved' => (bool) $artwork->is_approved,
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
'publish_at' => $artwork->publish_at?->toIso8601String(),
'artwork_status' => $artwork->artwork_status,
'artwork_timezone' => $artwork->artwork_timezone,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'file_name' => $artwork->file_name,
@@ -97,6 +102,11 @@ final class StudioController extends Controller
'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(),
'ai_status' => $artwork->ai_status,
'title_source' => $artwork->title_source ?: 'manual',
'description_source' => $artwork->description_source ?: 'manual',
'tags_source' => $artwork->tags_source ?: 'manual',
'category_source' => $artwork->category_source ?: 'manual',
// Versioning
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardPresenter;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class StudioNovaCardsController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
) {
}
public function index(Request $request): Response
{
$cards = NovaCard::query()
->with(['category', 'template', 'backgroundImage', 'tags', 'user.profile'])
->where('user_id', $request->user()->id)
->latest('updated_at')
->paginate(18)
->withQueryString();
$baseQuery = NovaCard::query()->where('user_id', $request->user()->id);
return Inertia::render('Studio/StudioCardsIndex', [
'cards' => $this->presenter->paginator($cards, false, $request->user()),
'stats' => [
'all' => (clone $baseQuery)->count(),
'drafts' => (clone $baseQuery)->where('status', NovaCard::STATUS_DRAFT)->count(),
'processing' => (clone $baseQuery)->where('status', NovaCard::STATUS_PROCESSING)->count(),
'published' => (clone $baseQuery)->where('status', NovaCard::STATUS_PUBLISHED)->count(),
],
'editorOptions' => $this->presenter->options(),
'endpoints' => [
'create' => route('studio.cards.create'),
'editPattern' => route('studio.cards.edit', ['id' => '__CARD__']),
'previewPattern' => route('studio.cards.preview', ['id' => '__CARD__']),
'analyticsPattern' => route('studio.cards.analytics', ['id' => '__CARD__']),
'draftStore' => route('api.cards.drafts.store'),
],
]);
}
public function create(Request $request): Response
{
$options = $this->presenter->optionsWithPresets($this->presenter->options(), $request->user());
return Inertia::render('Studio/StudioCardEditor', [
'card' => null,
'previewMode' => false,
'mobileSteps' => $this->mobileSteps(),
'editorOptions' => $options,
'endpoints' => $this->editorEndpoints(),
]);
}
public function edit(Request $request, int $id): Response
{
$card = $this->ownedCard($request, $id);
$options = $this->presenter->optionsWithPresets($this->presenter->options(), $request->user());
return Inertia::render('Studio/StudioCardEditor', [
'card' => $this->presenter->card($card, true, $request->user()),
'versions' => $this->versionPayloads($card),
'previewMode' => false,
'mobileSteps' => $this->mobileSteps(),
'editorOptions' => $options,
'endpoints' => $this->editorEndpoints(),
]);
}
public function preview(Request $request, int $id): Response
{
$card = $this->ownedCard($request, $id);
$options = $this->presenter->optionsWithPresets($this->presenter->options(), $request->user());
return Inertia::render('Studio/StudioCardEditor', [
'card' => $this->presenter->card($card, true, $request->user()),
'versions' => $this->versionPayloads($card),
'previewMode' => true,
'mobileSteps' => $this->mobileSteps(),
'editorOptions' => $options,
'endpoints' => $this->editorEndpoints(),
]);
}
public function analytics(Request $request, int $id): Response
{
$card = $this->ownedCard($request, $id);
return Inertia::render('Studio/StudioCardAnalytics', [
'card' => $this->presenter->card($card, false, $request->user()),
'analytics' => [
'views' => (int) $card->views_count,
'likes' => (int) $card->likes_count,
'favorites' => (int) $card->favorites_count,
'saves' => (int) $card->saves_count,
'remixes' => (int) $card->remixes_count,
'comments' => (int) $card->comments_count,
'challenge_entries' => (int) $card->challenge_entries_count,
'shares' => (int) $card->shares_count,
'downloads' => (int) $card->downloads_count,
'trending_score' => (float) $card->trending_score,
'last_engaged_at' => optional($card->last_engaged_at)?->toDayDateTimeString(),
],
]);
}
public function remix(Request $request, int $id): RedirectResponse
{
$source = NovaCard::query()->published()->findOrFail($id);
abort_unless($source->canBeViewedBy($request->user()), 404);
$card = app(\App\Services\NovaCards\NovaCardDraftService::class)->createRemix($request->user(), $source->loadMissing('tags'));
return redirect()->route('studio.cards.edit', ['id' => $card->id]);
}
private function mobileSteps(): array
{
return [
['key' => 'format', 'label' => 'Format', 'description' => 'Choose the canvas shape and basic direction.'],
['key' => 'background', 'label' => 'Template & Background', 'description' => 'Pick the visual foundation for the card.'],
['key' => 'content', 'label' => 'Text', 'description' => 'Write the quote, author, and source.'],
['key' => 'style', 'label' => 'Style', 'description' => 'Fine-tune typography and layout.'],
['key' => 'preview', 'label' => 'Preview', 'description' => 'Check the live composition before publish.'],
['key' => 'publish', 'label' => 'Publish', 'description' => 'Review metadata and release settings.'],
];
}
private function editorEndpoints(): array
{
return [
'draftStore' => route('api.cards.drafts.store'),
'draftShowPattern' => route('api.cards.drafts.show', ['id' => '__CARD__']),
'draftUpdatePattern' => route('api.cards.drafts.update', ['id' => '__CARD__']),
'draftAutosavePattern' => route('api.cards.drafts.autosave', ['id' => '__CARD__']),
'draftBackgroundPattern' => route('api.cards.drafts.background', ['id' => '__CARD__']),
'draftRenderPattern' => route('api.cards.drafts.render', ['id' => '__CARD__']),
'draftPublishPattern' => route('api.cards.drafts.publish', ['id' => '__CARD__']),
'draftDeletePattern' => route('api.cards.drafts.destroy', ['id' => '__CARD__']),
'draftVersionsPattern' => route('api.cards.drafts.versions', ['id' => '__CARD__']),
'draftRestorePattern' => route('api.cards.drafts.restore', ['id' => '__CARD__', 'versionId' => '__VERSION__']),
'remixPattern' => route('api.cards.remix', ['id' => '__CARD__']),
'duplicatePattern' => route('api.cards.duplicate', ['id' => '__CARD__']),
'collectionsIndex' => route('api.cards.collections.index'),
'collectionsStore' => route('api.cards.collections.store'),
'savePattern' => route('api.cards.save', ['id' => '__CARD__']),
'likePattern' => route('api.cards.like', ['id' => '__CARD__']),
'favoritePattern' => route('api.cards.favorite', ['id' => '__CARD__']),
'challengeSubmitPattern' => route('api.cards.challenges.submit', ['challengeId' => '__CHALLENGE__', 'id' => '__CARD__']),
'studioCards' => route('studio.cards.index'),
'studioAnalyticsPattern' => route('studio.cards.analytics', ['id' => '__CARD__']),
// v3 endpoints
'presetsIndex' => route('api.cards.presets.index'),
'presetsStore' => route('api.cards.presets.store'),
'presetUpdatePattern' => route('api.cards.presets.update', ['id' => '__PRESET__']),
'presetDestroyPattern' => route('api.cards.presets.destroy', ['id' => '__PRESET__']),
'presetApplyPattern' => route('api.cards.presets.apply', ['presetId' => '__PRESET__', 'cardId' => '__CARD__']),
'capturePresetPattern' => route('api.cards.presets.capture', ['cardId' => '__CARD__']),
'aiSuggestPattern' => route('api.cards.ai-suggest', ['id' => '__CARD__']),
'exportPattern' => route('api.cards.export.store', ['id' => '__CARD__']),
'exportStatusPattern' => route('api.cards.exports.show', ['exportId' => '__EXPORT__']),
];
}
private function versionPayloads(NovaCard $card): array
{
return $card->versions()->latest('version_number')->get()->map(fn ($version): array => [
'id' => (int) $version->id,
'version_number' => (int) $version->version_number,
'label' => $version->label,
'created_at' => $version->created_at?->toISOString(),
'snapshot_json' => is_array($version->snapshot_json) ? $version->snapshot_json : [],
])->values()->all();
}
private function ownedCard(Request $request, int $id): NovaCard
{
return NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'versions'])
->where('user_id', $request->user()->id)
->findOrFail($id);
}
}