Save workspace changes
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\PinGroupActivityItemRequest;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupActivityItem;
|
||||
use App\Services\GroupActivityService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupActivityStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupActivityService $activity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewStudio', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupActivity', [
|
||||
'title' => $group->name . ' Activity',
|
||||
'description' => 'Track public and internal group events from one activity timeline.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'activity' => $this->activity->studioFeed($group, $request->user(), 30),
|
||||
'pinPattern' => $group->canPinActivity($request->user()) ? route('studio.groups.activity.pin', ['group' => $group, 'item' => '__ITEM__']) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function pin(PinGroupActivityItemRequest $request, Group $group, GroupActivityItem $item): RedirectResponse
|
||||
{
|
||||
$this->authorize('pinActivity', $group);
|
||||
abort_unless((int) $item->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->activity->pin($item, $request->user(), (bool) $request->boolean('is_pinned', ! $item->is_pinned));
|
||||
|
||||
return back()->with('success', 'Activity updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\StoreGroupAssetRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupAssetRequest;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupAsset;
|
||||
use App\Services\GroupAssetService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupAssetStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupAssetService $assets,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewStudio', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupAssets', [
|
||||
'title' => $group->name . ' Assets',
|
||||
'description' => 'Manage reusable group files, templates, brand assets, and reference packs.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->assets->studioListing($group, $request->user(), $request->only(['bucket', 'category', 'q', 'page', 'per_page'])),
|
||||
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
|
||||
'categoryOptions' => collect((array) config('groups.assets.categories', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.assets.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'statusOptions' => collect((array) config('groups.assets.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'storeUrl' => $group->canManageAssets($request->user()) ? route('studio.groups.assets.store', ['group' => $group]) : null,
|
||||
'updatePattern' => $group->canManageAssets($request->user()) ? route('studio.groups.assets.update', ['group' => $group, 'asset' => '__ASSET__']) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreGroupAssetRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageAssets', $group);
|
||||
|
||||
$this->assets->store($group, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Asset uploaded.');
|
||||
}
|
||||
|
||||
public function update(UpdateGroupAssetRequest $request, Group $group, GroupAsset $asset): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageAssets', $group);
|
||||
abort_unless((int) $asset->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->assets->update($asset, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Asset updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\AttachArtworkToGroupChallengeRequest;
|
||||
use App\Http\Requests\Groups\StoreGroupChallengeRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupChallengeRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Services\GroupChallengeService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupChallengeStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupChallengeService $challenges,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageChallenges', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupChallenges', [
|
||||
'title' => $group->name . ' Challenges',
|
||||
'description' => 'Run creative prompts, themed sprints, and public or internal participation campaigns.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->challenges->studioListing($group, $request->only(['bucket', 'page', 'per_page'])),
|
||||
'recentHistory' => $this->groups->recentHistory($group),
|
||||
'createUrl' => route('studio.groups.challenges.create', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageChallenges', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupChallengeEditor', [
|
||||
'title' => 'Create challenge',
|
||||
'description' => 'Set the timeline, rules, and participation model for a new group challenge.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'challenge' => null,
|
||||
'statusOptions' => collect((array) config('groups.challenges.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.challenges.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'participationScopeOptions' => collect((array) config('groups.challenges.participation_scopes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'judgingModeOptions' => collect((array) config('groups.challenges.judging_modes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
|
||||
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
|
||||
'storeUrl' => route('studio.groups.challenges.store', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreGroupChallengeRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageChallenges', $group);
|
||||
|
||||
$challenge = $this->challenges->create($group, $request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.groups.challenges.edit', ['group' => $group, 'challenge' => $challenge])
|
||||
->with('success', 'Challenge created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Group $group, GroupChallenge $challenge): Response
|
||||
{
|
||||
$this->authorize('manageChallenges', $group);
|
||||
abort_unless((int) $challenge->group_id === (int) $group->id, 404);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupChallengeEditor', [
|
||||
'title' => 'Edit challenge',
|
||||
'description' => 'Publish and curate challenge entries from one editing view.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'challenge' => $this->challenges->detailPayload($challenge, $request->user()),
|
||||
'statusOptions' => collect((array) config('groups.challenges.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.challenges.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'participationScopeOptions' => collect((array) config('groups.challenges.participation_scopes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'judgingModeOptions' => collect((array) config('groups.challenges.judging_modes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
|
||||
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
|
||||
'updateUrl' => route('studio.groups.challenges.update', ['group' => $group, 'challenge' => $challenge]),
|
||||
'publishUrl' => route('studio.groups.challenges.publish', ['group' => $group, 'challenge' => $challenge]),
|
||||
'attachArtworkUrl' => route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupChallengeRequest $request, Group $group, GroupChallenge $challenge): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageChallenges', $group);
|
||||
abort_unless((int) $challenge->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->challenges->update($challenge, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Challenge updated.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, Group $group, GroupChallenge $challenge): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageChallenges', $group);
|
||||
abort_unless((int) $challenge->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->challenges->publish($challenge, $request->user());
|
||||
|
||||
return back()->with('success', 'Challenge published.');
|
||||
}
|
||||
|
||||
public function attachArtwork(AttachArtworkToGroupChallengeRequest $request, Group $group, GroupChallenge $challenge): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageChallenges', $group);
|
||||
abort_unless((int) $challenge->group_id === (int) $group->id, 404);
|
||||
|
||||
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
|
||||
$this->challenges->attachArtwork($challenge, $artwork, $request->user());
|
||||
|
||||
return back()->with('success', 'Artwork attached to challenge.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\StoreGroupEventRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupEventRequest;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupEvent;
|
||||
use App\Services\GroupEventService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupEventStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupEventService $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageEvents', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupEvents', [
|
||||
'title' => $group->name . ' Events',
|
||||
'description' => 'Manage launches, milestones, streams, and timeline-aware group moments.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->events->studioListing($group, $request->only(['bucket', 'page', 'per_page'])),
|
||||
'recentHistory' => $this->groups->recentHistory($group),
|
||||
'createUrl' => route('studio.groups.events.create', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageEvents', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupEventEditor', [
|
||||
'title' => 'Create event',
|
||||
'description' => 'Schedule a release, internal session, livestream, or other group event.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'event' => null,
|
||||
'typeOptions' => collect((array) config('groups.events.types', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'statusOptions' => collect((array) config('groups.events.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.events.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
|
||||
'challengeOptions' => $group->challenges()->orderBy('title')->get(['id', 'title'])->map(fn ($challenge): array => ['id' => (int) $challenge->id, 'title' => $challenge->title])->values()->all(),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'storeUrl' => route('studio.groups.events.store', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreGroupEventRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageEvents', $group);
|
||||
|
||||
$event = $this->events->create($group, $request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.groups.events.edit', ['group' => $group, 'event' => $event])
|
||||
->with('success', 'Event created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Group $group, GroupEvent $event): Response
|
||||
{
|
||||
abort_unless($group->canManageEvents($request->user()) || $group->canPublishEventUpdates($request->user()), 403);
|
||||
abort_unless((int) $event->group_id === (int) $group->id, 404);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupEventEditor', [
|
||||
'title' => 'Edit event',
|
||||
'description' => 'Refine public details and publish event updates safely.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'event' => $this->events->detailPayload($event),
|
||||
'typeOptions' => collect((array) config('groups.events.types', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'statusOptions' => collect((array) config('groups.events.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.events.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
|
||||
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
|
||||
'challengeOptions' => $group->challenges()->orderBy('title')->get(['id', 'title'])->map(fn ($challenge): array => ['id' => (int) $challenge->id, 'title' => $challenge->title])->values()->all(),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'updateUrl' => route('studio.groups.events.update', ['group' => $group, 'event' => $event]),
|
||||
'publishUrl' => route('studio.groups.events.publish', ['group' => $group, 'event' => $event]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupEventRequest $request, Group $group, GroupEvent $event): RedirectResponse
|
||||
{
|
||||
abort_unless($group->canManageEvents($request->user()) || $group->canPublishEventUpdates($request->user()), 403);
|
||||
abort_unless((int) $event->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->events->update($event, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Event updated.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, Group $group, GroupEvent $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageEvents', $group);
|
||||
abort_unless((int) $event->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->events->publish($event, $request->user());
|
||||
|
||||
return back()->with('success', 'Event published.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\ReviewGroupJoinRequestRequest;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupJoinRequest;
|
||||
use App\Services\GroupJoinRequestService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupJoinRequestStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupJoinRequestService $joinRequests,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('reviewJoinRequests', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupJoinRequests', [
|
||||
'title' => $group->name . ' Join requests',
|
||||
'description' => 'Review incoming applications, compare requested roles, and approve or reject requests with audit history.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->joinRequests->mapRequests($group, $request->user(), $request->only(['bucket', 'page', 'per_page'])),
|
||||
'recentHistory' => $this->groups->recentHistory($group),
|
||||
'roleOptions' => [
|
||||
['value' => Group::ROLE_MEMBER, 'label' => 'Contributor'],
|
||||
['value' => Group::ROLE_EDITOR, 'label' => 'Editor'],
|
||||
['value' => Group::ROLE_ADMIN, 'label' => 'Admin'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(ReviewGroupJoinRequestRequest $request, Group $group, GroupJoinRequest $joinRequest): RedirectResponse
|
||||
{
|
||||
$this->authorize('reviewJoinRequests', $group);
|
||||
abort_unless((int) $joinRequest->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->joinRequests->approve(
|
||||
$joinRequest,
|
||||
$request->user(),
|
||||
$request->validated('role'),
|
||||
$request->validated('review_notes'),
|
||||
);
|
||||
|
||||
return back()->with('success', 'Join request approved.');
|
||||
}
|
||||
|
||||
public function reject(ReviewGroupJoinRequestRequest $request, Group $group, GroupJoinRequest $joinRequest): RedirectResponse
|
||||
{
|
||||
$this->authorize('reviewJoinRequests', $group);
|
||||
abort_unless((int) $joinRequest->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->joinRequests->reject(
|
||||
$joinRequest,
|
||||
$request->user(),
|
||||
$request->validated('review_notes'),
|
||||
);
|
||||
|
||||
return back()->with('success', 'Join request rejected.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\StoreGroupPostRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupPostRequest;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupPost;
|
||||
use App\Services\GroupPostService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupPostStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupPostService $posts,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('managePosts', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupPosts', [
|
||||
'title' => $group->name . ' Posts',
|
||||
'description' => 'Publish announcements, releases, recruitment calls, and pinned updates from the group.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->posts->studioListing($group, $request->only(['bucket', 'page', 'per_page'])),
|
||||
'recentHistory' => $this->groups->recentHistory($group),
|
||||
'createUrl' => route('studio.groups.posts.create', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('managePosts', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupPostEditor', [
|
||||
'title' => 'Create post',
|
||||
'description' => 'Draft a new public announcement for the group.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'post' => null,
|
||||
'typeOptions' => $this->typeOptions(),
|
||||
'storeUrl' => route('studio.groups.posts.store', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreGroupPostRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('managePosts', $group);
|
||||
|
||||
$post = $this->posts->create($group, $request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.groups.posts.edit', ['group' => $group, 'post' => $post])
|
||||
->with('success', 'Draft created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Group $group, GroupPost $post): Response
|
||||
{
|
||||
$this->authorize('managePosts', $group);
|
||||
abort_unless((int) $post->group_id === (int) $group->id, 404);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupPostEditor', [
|
||||
'title' => 'Edit post',
|
||||
'description' => 'Update copy, publish state, and pinned status for this group post.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'post' => $this->posts->mapStudioPost($group, $post),
|
||||
'typeOptions' => $this->typeOptions(),
|
||||
'storeUrl' => null,
|
||||
'updateUrl' => route('studio.groups.posts.update', ['group' => $group, 'post' => $post]),
|
||||
'publishUrl' => route('studio.groups.posts.publish', ['group' => $group, 'post' => $post]),
|
||||
'pinUrl' => route('studio.groups.posts.pin', ['group' => $group, 'post' => $post]),
|
||||
'archiveUrl' => route('studio.groups.posts.archive', ['group' => $group, 'post' => $post]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupPostRequest $request, Group $group, GroupPost $post): RedirectResponse
|
||||
{
|
||||
$this->authorize('managePosts', $group);
|
||||
abort_unless((int) $post->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->posts->update($post, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Post updated.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, Group $group, GroupPost $post): RedirectResponse
|
||||
{
|
||||
$this->authorize('publishPosts', $group);
|
||||
abort_unless((int) $post->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->posts->publish($post, $request->user());
|
||||
|
||||
return back()->with('success', 'Post published.');
|
||||
}
|
||||
|
||||
public function pin(Request $request, Group $group, GroupPost $post): RedirectResponse
|
||||
{
|
||||
$this->authorize('pinPosts', $group);
|
||||
abort_unless((int) $post->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->posts->pin($post, $request->user(), ! $post->is_pinned);
|
||||
|
||||
return back()->with('success', $post->is_pinned ? 'Post unpinned.' : 'Post pinned.');
|
||||
}
|
||||
|
||||
public function archive(Request $request, Group $group, GroupPost $post): RedirectResponse
|
||||
{
|
||||
$this->authorize('managePosts', $group);
|
||||
abort_unless((int) $post->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->posts->archive($post, $request->user());
|
||||
|
||||
return back()->with('success', 'Post archived.');
|
||||
}
|
||||
|
||||
private function typeOptions(): array
|
||||
{
|
||||
return [
|
||||
['value' => GroupPost::TYPE_ANNOUNCEMENT, 'label' => 'Announcement'],
|
||||
['value' => GroupPost::TYPE_RELEASE, 'label' => 'Release'],
|
||||
['value' => GroupPost::TYPE_RECRUITMENT, 'label' => 'Recruitment'],
|
||||
['value' => GroupPost::TYPE_UPDATE, 'label' => 'Update'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\AttachArtworkToGroupProjectRequest;
|
||||
use App\Http\Requests\Groups\AttachAssetToGroupProjectRequest;
|
||||
use App\Http\Requests\Groups\StoreGroupMilestoneRequest;
|
||||
use App\Http\Requests\Groups\StoreGroupProjectRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupMilestoneRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupProjectRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupProjectStatusRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupAsset;
|
||||
use App\Models\GroupPost;
|
||||
use App\Models\GroupProject;
|
||||
use App\Models\GroupProjectMilestone;
|
||||
use App\Services\GroupProjectService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupProjectStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupProjectService $projects,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageProjects', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupProjects', [
|
||||
'title' => $group->name . ' Projects',
|
||||
'description' => 'Manage structured group releases, collaboration hubs, and showcase pages.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->projects->studioListing($group, $request->only(['bucket', 'page', 'per_page'])),
|
||||
'recentHistory' => $this->groups->recentHistory($group),
|
||||
'createUrl' => route('studio.groups.projects.create', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageProjects', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupProjectEditor', [
|
||||
'title' => 'Create project',
|
||||
'description' => 'Set up a project page that can collect artworks, assets, notes, and release state.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'project' => null,
|
||||
'statusOptions' => collect((array) config('groups.projects.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.projects.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'memberOptions' => $this->projects->memberOptions($group->loadMissing('owner.profile')),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
|
||||
'postOptions' => $group->posts()->latest('updated_at')->limit(20)->get(['id', 'title'])->map(fn (GroupPost $post): array => ['id' => (int) $post->id, 'title' => $post->title])->values()->all(),
|
||||
'storeUrl' => route('studio.groups.projects.store', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreGroupProjectRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageProjects', $group);
|
||||
|
||||
$project = $this->projects->create($group, $request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.groups.projects.edit', ['group' => $group, 'project' => $project])
|
||||
->with('success', 'Project created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Group $group, GroupProject $project): Response
|
||||
{
|
||||
$this->authorize('manageProjects', $group);
|
||||
abort_unless((int) $project->group_id === (int) $group->id, 404);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupProjectEditor', [
|
||||
'title' => 'Edit project',
|
||||
'description' => 'Update status, attachments, and project presentation.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'project' => $this->projects->detailPayload($project, $request->user()),
|
||||
'statusOptions' => collect((array) config('groups.projects.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.projects.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'memberOptions' => $this->projects->memberOptions($group->loadMissing('owner.profile')),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
|
||||
'assetOptions' => $group->assets()->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn (GroupAsset $asset): array => ['id' => (int) $asset->id, 'title' => $asset->title])->values()->all(),
|
||||
'postOptions' => $group->posts()->latest('updated_at')->limit(20)->get(['id', 'title'])->map(fn (GroupPost $post): array => ['id' => (int) $post->id, 'title' => $post->title])->values()->all(),
|
||||
'updateUrl' => route('studio.groups.projects.update', ['group' => $group, 'project' => $project]),
|
||||
'statusUrl' => route('studio.groups.projects.status', ['group' => $group, 'project' => $project]),
|
||||
'attachArtworkUrl' => route('studio.groups.projects.attach-artwork', ['group' => $group, 'project' => $project]),
|
||||
'attachAssetUrl' => route('studio.groups.projects.attach-asset', ['group' => $group, 'project' => $project]),
|
||||
'storeMilestoneUrl' => route('studio.groups.projects.milestones.store', ['group' => $group, 'project' => $project]),
|
||||
'updateMilestonePattern' => route('studio.groups.projects.milestones.update', ['group' => $group, 'project' => $project, 'milestone' => '__MILESTONE__']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupProjectRequest $request, Group $group, GroupProject $project): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageProjects', $group);
|
||||
abort_unless((int) $project->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->projects->update($project, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Project updated.');
|
||||
}
|
||||
|
||||
public function attachArtwork(AttachArtworkToGroupProjectRequest $request, Group $group, GroupProject $project): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageProjects', $group);
|
||||
abort_unless((int) $project->group_id === (int) $group->id, 404);
|
||||
|
||||
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
|
||||
$this->projects->attachArtwork($project, $artwork, $request->user());
|
||||
|
||||
return back()->with('success', 'Artwork attached to project.');
|
||||
}
|
||||
|
||||
public function attachAsset(AttachAssetToGroupProjectRequest $request, Group $group, GroupProject $project): RedirectResponse
|
||||
{
|
||||
$this->authorize('attachAssetsToProjects', $group);
|
||||
abort_unless((int) $project->group_id === (int) $group->id, 404);
|
||||
|
||||
$asset = GroupAsset::query()->findOrFail((int) $request->validated('asset_id'));
|
||||
$this->projects->attachAsset($project, $asset, $request->user());
|
||||
|
||||
return back()->with('success', 'Asset attached to project.');
|
||||
}
|
||||
|
||||
public function status(UpdateGroupProjectStatusRequest $request, Group $group, GroupProject $project): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageProjects', $group);
|
||||
abort_unless((int) $project->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->projects->updateStatus($project, $request->user(), (string) $request->validated('status'));
|
||||
|
||||
return back()->with('success', 'Project status updated.');
|
||||
}
|
||||
|
||||
public function storeMilestone(StoreGroupMilestoneRequest $request, Group $group, GroupProject $project): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageMilestones', $group);
|
||||
abort_unless((int) $project->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->projects->createMilestone($project, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Project milestone created.');
|
||||
}
|
||||
|
||||
public function updateMilestone(UpdateGroupMilestoneRequest $request, Group $group, GroupProject $project, GroupProjectMilestone $milestone): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageMilestones', $group);
|
||||
abort_unless((int) $project->group_id === (int) $group->id, 404);
|
||||
abort_unless((int) $milestone->group_project_id === (int) $project->id, 404);
|
||||
|
||||
$this->projects->updateMilestone($milestone, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Project milestone updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\UpdateGroupRecruitmentRequest;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupRecruitmentService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupRecruitmentStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupRecruitmentService $recruitment,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageRecruitment', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupRecruitment', [
|
||||
'title' => $group->name . ' Recruitment',
|
||||
'description' => 'Show open roles publicly, describe your workflow, and control how applicants should get in touch.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'recruitment' => $this->groups->recruitmentPayload($group),
|
||||
'contactModes' => [
|
||||
['value' => 'join_request', 'label' => 'Join request'],
|
||||
['value' => 'direct_message', 'label' => 'Direct message'],
|
||||
['value' => 'external_link', 'label' => 'External link'],
|
||||
],
|
||||
'visibilityOptions' => [
|
||||
['value' => 'public', 'label' => 'Public'],
|
||||
['value' => 'members_only', 'label' => 'Members only'],
|
||||
['value' => 'private', 'label' => 'Private'],
|
||||
],
|
||||
'roleOptions' => collect(config('groups.recruitment.roles', []))
|
||||
->map(fn (string $role): array => ['value' => $role, 'label' => $role])
|
||||
->values()
|
||||
->all(),
|
||||
'skillOptions' => collect(config('groups.recruitment.skills', []))
|
||||
->map(fn (string $skill): array => ['value' => $skill, 'label' => $skill])
|
||||
->values()
|
||||
->all(),
|
||||
'updateUrl' => route('studio.groups.recruitment.update', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupRecruitmentRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageRecruitment', $group);
|
||||
|
||||
$this->recruitment->upsert($group, $request->validated(), $request->user());
|
||||
|
||||
return back()->with('success', 'Recruitment profile updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\AttachArtworkToGroupReleaseRequest;
|
||||
use App\Http\Requests\Groups\AttachContributorToGroupReleaseRequest;
|
||||
use App\Http\Requests\Groups\StoreGroupMilestoneRequest;
|
||||
use App\Http\Requests\Groups\StoreGroupReleaseRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupMilestoneRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupReleaseRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupReleaseStageRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupRelease;
|
||||
use App\Models\GroupReleaseMilestone;
|
||||
use App\Models\User;
|
||||
use App\Services\GroupReleaseService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupReleaseStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupReleaseService $releases,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageReleases', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupReleases', [
|
||||
'title' => $group->name . ' Releases',
|
||||
'description' => 'Manage release pipelines, contributors, and publication stages for major group drops.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->releases->studioListing($group, $request->only(['bucket', 'page', 'per_page'])),
|
||||
'createUrl' => route('studio.groups.releases.create', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageReleases', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupReleaseEditor', [
|
||||
'title' => 'Create release',
|
||||
'description' => 'Build a release page and move it from concept through publishing.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'release' => null,
|
||||
'statusOptions' => collect((array) config('groups.releases.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => str_replace('_', ' ', ucfirst($value))])->values()->all(),
|
||||
'stageOptions' => collect((array) config('groups.releases.stages', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.releases.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'memberOptions' => $this->releases->memberOptions($group->loadMissing('owner.profile')),
|
||||
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
|
||||
'storeUrl' => route('studio.groups.releases.store', ['group' => $group]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreGroupReleaseRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageReleases', $group);
|
||||
|
||||
$release = $this->releases->create($group, $request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.groups.releases.show', ['group' => $group, 'release' => $release])
|
||||
->with('success', 'Release created.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Group $group, GroupRelease $release): Response
|
||||
{
|
||||
$this->authorize('manageReleases', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupReleaseEditor', [
|
||||
'title' => 'Edit release',
|
||||
'description' => 'Update the release story, stage, contributors, and publish plan.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'release' => $this->releases->detailPayload($release, $request->user()),
|
||||
'statusOptions' => collect((array) config('groups.releases.statuses', []))->map(fn (string $value): array => ['value' => $value, 'label' => str_replace('_', ' ', ucfirst($value))])->values()->all(),
|
||||
'stageOptions' => collect((array) config('groups.releases.stages', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'visibilityOptions' => collect((array) config('groups.releases.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
|
||||
'memberOptions' => $this->releases->memberOptions($group->loadMissing('owner.profile')),
|
||||
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
|
||||
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
|
||||
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
|
||||
'updateUrl' => route('studio.groups.releases.update', ['group' => $group, 'release' => $release]),
|
||||
'stageUrl' => route('studio.groups.releases.stage', ['group' => $group, 'release' => $release]),
|
||||
'publishUrl' => route('studio.groups.releases.publish', ['group' => $group, 'release' => $release]),
|
||||
'attachArtworkUrl' => route('studio.groups.releases.attach-artwork', ['group' => $group, 'release' => $release]),
|
||||
'attachContributorUrl' => route('studio.groups.releases.attach-contributor', ['group' => $group, 'release' => $release]),
|
||||
'storeMilestoneUrl' => route('studio.groups.releases.milestones.store', ['group' => $group, 'release' => $release]),
|
||||
'updateMilestonePattern' => route('studio.groups.releases.milestones.update', ['group' => $group, 'release' => $release, 'milestone' => '__MILESTONE__']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupReleaseRequest $request, Group $group, GroupRelease $release): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageReleases', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->releases->update($release, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Release updated.');
|
||||
}
|
||||
|
||||
public function stage(UpdateGroupReleaseStageRequest $request, Group $group, GroupRelease $release): RedirectResponse
|
||||
{
|
||||
$this->authorize('moveReleaseStage', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->releases->updateStage($release, $request->user(), (string) $request->validated('current_stage'));
|
||||
|
||||
return back()->with('success', 'Release stage updated.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, Group $group, GroupRelease $release): RedirectResponse
|
||||
{
|
||||
$this->authorize('publishReleases', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->releases->publish($release, $request->user());
|
||||
|
||||
return back()->with('success', 'Release published.');
|
||||
}
|
||||
|
||||
public function attachArtwork(AttachArtworkToGroupReleaseRequest $request, Group $group, GroupRelease $release): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageReleases', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
|
||||
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
|
||||
$this->releases->attachArtwork($release, $artwork, $request->user());
|
||||
|
||||
return back()->with('success', 'Artwork attached to release.');
|
||||
}
|
||||
|
||||
public function attachContributor(AttachContributorToGroupReleaseRequest $request, Group $group, GroupRelease $release): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageReleases', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
|
||||
$contributor = User::query()->findOrFail((int) $request->validated('user_id'));
|
||||
$this->releases->attachContributor($release, $contributor, $request->user(), $request->validated('role_label'));
|
||||
|
||||
return back()->with('success', 'Contributor attached to release.');
|
||||
}
|
||||
|
||||
public function storeMilestone(StoreGroupMilestoneRequest $request, Group $group, GroupRelease $release): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageMilestones', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
|
||||
$this->releases->createMilestone($release, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Release milestone created.');
|
||||
}
|
||||
|
||||
public function updateMilestone(UpdateGroupMilestoneRequest $request, Group $group, GroupRelease $release, GroupReleaseMilestone $milestone): RedirectResponse
|
||||
{
|
||||
$this->authorize('manageMilestones', $group);
|
||||
abort_unless((int) $release->group_id === (int) $group->id, 404);
|
||||
abort_unless((int) $milestone->group_release_id === (int) $release->id, 404);
|
||||
|
||||
$this->releases->updateMilestone($milestone, $request->user(), $request->validated());
|
||||
|
||||
return back()->with('success', 'Release milestone updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupDiscoveryService;
|
||||
use App\Services\GroupReputationService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupReputationStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupReputationService $reputation,
|
||||
private readonly GroupDiscoveryService $discovery,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewReputationDashboard', $group);
|
||||
|
||||
$this->reputation->refreshGroup($group);
|
||||
$metrics = $this->discovery->refresh($group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupReputation', [
|
||||
'title' => $group->name . ' Reputation',
|
||||
'description' => 'Review contributor reliability, badge unlocks, and internal trust metrics.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'reputation' => $this->reputation->summary($group),
|
||||
'trustSignals' => $this->reputation->trustSignals($group),
|
||||
'metrics' => [
|
||||
'freshness_score' => (float) $metrics->freshness_score,
|
||||
'activity_score' => (float) $metrics->activity_score,
|
||||
'release_score' => (float) $metrics->release_score,
|
||||
'trust_score' => (float) $metrics->trust_score,
|
||||
'collaboration_score' => (float) $metrics->collaboration_score,
|
||||
'last_calculated_at' => $metrics->last_calculated_at?->toISOString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\ReviewGroupArtworkRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupReviewStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupArtworkReviewService $reviews,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewStudio', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupReviewQueue', [
|
||||
'title' => $group->name . ' Review queue',
|
||||
'description' => 'Approve, reject, or request changes for artwork submitted under this group identity.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->reviews->listing($group, $request->user(), $request->only(['bucket', 'page', 'per_page'])),
|
||||
'recentHistory' => $this->groups->recentHistory($group),
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(ReviewGroupArtworkRequest $request, Group $group, Artwork $artwork): RedirectResponse
|
||||
{
|
||||
$this->authorize('reviewSubmissions', $group);
|
||||
$this->reviews->approve($group, $artwork, $request->user(), $request->validated('review_notes'));
|
||||
|
||||
return back()->with('success', 'Artwork approved and published.');
|
||||
}
|
||||
|
||||
public function needsChanges(ReviewGroupArtworkRequest $request, Group $group, Artwork $artwork): RedirectResponse
|
||||
{
|
||||
$this->authorize('reviewSubmissions', $group);
|
||||
$this->reviews->requestChanges($group, $artwork, $request->user(), $request->validated('review_notes'));
|
||||
|
||||
return back()->with('success', 'Changes requested from the uploader.');
|
||||
}
|
||||
|
||||
public function reject(ReviewGroupArtworkRequest $request, Group $group, Artwork $artwork): RedirectResponse
|
||||
{
|
||||
$this->authorize('reviewSubmissions', $group);
|
||||
$this->reviews->reject($group, $artwork, $request->user(), $request->validated('review_notes'));
|
||||
|
||||
return back()->with('success', 'Artwork rejected.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Groups\StoreGroupRequest;
|
||||
use App\Http\Requests\Groups\UpdateGroupRequest;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupMembershipService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Services\GroupJoinRequestService;
|
||||
use App\Services\GroupReputationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GroupStudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly GroupMembershipService $memberships,
|
||||
private readonly GroupJoinRequestService $joinRequests,
|
||||
private readonly GroupArtworkReviewService $artworkReviews,
|
||||
private readonly GroupReputationService $reputation,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$groups = Group::query()
|
||||
->with(['owner.profile', 'members'])
|
||||
->where(function ($query) use ($user): void {
|
||||
$query->where('owner_user_id', $user->id)
|
||||
->orWhereHas('members', function ($memberQuery) use ($user): void {
|
||||
$memberQuery->where('user_id', $user->id)
|
||||
->where('status', Group::STATUS_ACTIVE);
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Group $group): array => $this->groups->mapGroupCard($group, $user))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('Studio/StudioGroupsIndex', [
|
||||
'title' => 'Groups',
|
||||
'description' => 'Create collective publishing identities, manage memberships, and switch into shared artwork and collection workflows.',
|
||||
'groups' => $groups,
|
||||
'pendingInvites' => $this->memberships->pendingInvitationsForUser($user),
|
||||
'endpoints' => [
|
||||
'create' => route('studio.groups.create'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
$this->authorize('create', Group::class);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupCreate', [
|
||||
'title' => 'Create Group',
|
||||
'description' => 'Set up a shared publishing identity for collaborative uploads and collections.',
|
||||
'visibilityOptions' => [
|
||||
['value' => Group::VISIBILITY_PUBLIC, 'label' => 'Public'],
|
||||
['value' => Group::VISIBILITY_UNLISTED, 'label' => 'Unlisted'],
|
||||
['value' => Group::VISIBILITY_PRIVATE, 'label' => 'Private'],
|
||||
],
|
||||
'membershipPolicyOptions' => [
|
||||
['value' => Group::MEMBERSHIP_INVITE_ONLY, 'label' => 'Invite only'],
|
||||
],
|
||||
'endpoints' => [
|
||||
'store' => route('studio.groups.store'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreGroupRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', Group::class);
|
||||
|
||||
$group = $this->groups->createGroup($request->user(), $request->validated());
|
||||
|
||||
return redirect()->route('studio.groups.show', ['group' => $group]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewStudio', $group);
|
||||
|
||||
$viewer = $request->user();
|
||||
|
||||
return Inertia::render('Studio/StudioGroupDashboard', [
|
||||
'title' => $group->name,
|
||||
'description' => $group->headline ?: 'Shared publishing overview for this group.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $viewer),
|
||||
'dashboard' => $this->groups->studioDashboardSummary($group),
|
||||
'draftsPendingAction' => $this->groups->studioArtworkPreviewItems($group, 'drafts', 4),
|
||||
'recentArtworks' => $this->groups->studioArtworkPreviewItems($group, 'published', 6),
|
||||
'recentCollections' => $this->groups->studioCollectionPreviewItems($group, 4),
|
||||
'members' => $this->memberships->mapMembers($group, $viewer),
|
||||
'recentPosts' => $this->groups->recentPostCards($group, 3),
|
||||
'recentProjects' => $this->groups->recentProjectCards($group, $viewer, 3),
|
||||
'recentReleases' => $this->groups->recentReleaseCards($group, $viewer, 3),
|
||||
'recentChallenges' => $this->groups->recentChallengeCards($group, $viewer, 3),
|
||||
'recentEvents' => $this->groups->recentEventCards($group, $viewer, 3),
|
||||
'recentActivity' => $this->groups->studioActivityFeed($group, $viewer, 8),
|
||||
'recruitment' => $this->groups->recruitmentPayload($group),
|
||||
'reputationSummary' => $this->reputation->summary($group),
|
||||
'trustSignals' => $this->reputation->trustSignals($group),
|
||||
'pendingJoinRequests' => $group->canReviewJoinRequests($viewer)
|
||||
? $this->joinRequests->mapRequests($group, $viewer, ['bucket' => 'pending', 'per_page' => 4])['items']
|
||||
: [],
|
||||
'reviewQueuePreview' => $this->artworkReviews->listing($group, $viewer, ['bucket' => 'submitted', 'per_page' => 4])['items'],
|
||||
'recentHistory' => $this->groups->recentHistory($group, 6),
|
||||
]);
|
||||
}
|
||||
|
||||
public function artworks(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewStudio', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupArtworks', [
|
||||
'title' => $group->name . ' Artworks',
|
||||
'description' => 'Browse every artwork published through this shared identity.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->groups->studioArtworkListing($group, $request->only(['bucket', 'q', 'page', 'per_page'])),
|
||||
'uploadUrl' => route('upload', ['group' => $group->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function collections(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewStudio', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupCollections', [
|
||||
'title' => $group->name . ' Collections',
|
||||
'description' => 'Manage collections published under this group identity.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'listing' => $this->groups->studioCollectionListing($group, $request->only(['bucket', 'q', 'page', 'per_page'])),
|
||||
'createUrl' => route('settings.collections.create', ['group' => $group->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function members(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('viewStudio', $group);
|
||||
$viewer = $request->user();
|
||||
$canManageMembers = $group->canManageMembers($viewer);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupMembers', [
|
||||
'title' => $group->name . ' Members',
|
||||
'description' => 'Invite, remove, and promote the people who can publish and curate under this group.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $viewer),
|
||||
'members' => $this->memberships->mapMembers($group, $viewer),
|
||||
'canManageMembers' => $canManageMembers,
|
||||
'permissionOverrideOptions' => array_map(static fn (string $permission): array => [
|
||||
'value' => $permission,
|
||||
'label' => str_replace('_', ' ', $permission),
|
||||
], Group::allowedPermissionOverrides()),
|
||||
'endpoints' => $canManageMembers ? [
|
||||
'invite' => route('studio.groups.members.store', ['group' => $group]),
|
||||
'invitations' => route('studio.groups.invitations', ['group' => $group]),
|
||||
'updatePattern' => route('studio.groups.members.update', ['group' => $group, 'member' => '__MEMBER__']),
|
||||
'permissionsPattern' => route('studio.groups.members.permissions.update', ['group' => $group, 'member' => '__MEMBER__']),
|
||||
'transferPattern' => route('studio.groups.members.transfer', ['group' => $group, 'member' => '__MEMBER__']),
|
||||
'deletePattern' => route('studio.groups.members.destroy', ['group' => $group, 'member' => '__MEMBER__']),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function invitations(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('manageMembers', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupInvitations', [
|
||||
'title' => $group->name . ' Invitations',
|
||||
'description' => 'Manage outstanding invites, resend collaboration roles, and review recent invite history for this group.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'members' => $this->memberships->mapMembers($group, $request->user()),
|
||||
'invitations' => $this->memberships->mapInvitations($group, $request->user()),
|
||||
'endpoints' => [
|
||||
'invite' => route('studio.groups.members.store', ['group' => $group]),
|
||||
'deletePattern' => route('studio.groups.invitations.destroy', ['group' => $group, 'invitation' => '__INVITATION__']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(Request $request, Group $group): Response
|
||||
{
|
||||
$this->authorize('update', $group);
|
||||
|
||||
return Inertia::render('Studio/StudioGroupSettings', [
|
||||
'title' => $group->name . ' Settings',
|
||||
'description' => 'Update the public presentation and collaboration defaults for this group.',
|
||||
'studioGroup' => $this->groups->mapGroupDetail($group, $request->user()),
|
||||
'featuredArtworkOptions' => $this->groups->studioFeaturedArtworkOptions($group),
|
||||
'visibilityOptions' => [
|
||||
['value' => Group::VISIBILITY_PUBLIC, 'label' => 'Public'],
|
||||
['value' => Group::VISIBILITY_UNLISTED, 'label' => 'Unlisted'],
|
||||
['value' => Group::VISIBILITY_PRIVATE, 'label' => 'Private'],
|
||||
],
|
||||
'membershipPolicyOptions' => [
|
||||
['value' => Group::MEMBERSHIP_INVITE_ONLY, 'label' => 'Invite only'],
|
||||
],
|
||||
'endpoints' => [
|
||||
'update' => route('studio.groups.update', ['group' => $group]),
|
||||
'archive' => route('studio.groups.archive', ['group' => $group]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateGroupRequest $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $group);
|
||||
|
||||
$group = $this->groups->updateGroup($group, $request->validated(), $request->user());
|
||||
|
||||
return redirect()->route('studio.groups.settings', ['group' => $group]);
|
||||
}
|
||||
|
||||
public function archive(Request $request, Group $group): RedirectResponse
|
||||
{
|
||||
$this->authorize('archive', $group);
|
||||
|
||||
$group = $this->groups->archiveGroup($group, $request->user());
|
||||
|
||||
return redirect()->route('studio.groups.settings', ['group' => $group]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
<?php
|
||||
|
||||
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\ArtworkEvolutionService;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkAttributionService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* JSON API endpoints for the Studio artwork manager.
|
||||
*/
|
||||
final class StudioArtworksApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StudioArtworkQueryService $queryService,
|
||||
private readonly StudioBulkActionService $bulkService,
|
||||
private readonly ArtworkVersioningService $versioningService,
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
private readonly TagDiscoveryService $tagDiscoveryService,
|
||||
private readonly TagService $tagService,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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, ArtworkAttributionService $attribution): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$evolution = app(ArtworkEvolutionService::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'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',
|
||||
'group' => 'sometimes|nullable|string|max:90',
|
||||
'primary_author_user_id' => 'sometimes|nullable|integer|min:1',
|
||||
'contributor_user_ids' => 'sometimes|array|max:20',
|
||||
'contributor_user_ids.*' => 'integer|min:1',
|
||||
'contributor_credits' => 'sometimes|array|max:20',
|
||||
'contributor_credits.*.user_id' => 'required|integer|min:1',
|
||||
'contributor_credits.*.credit_role' => 'nullable|string|max:80',
|
||||
'contributor_credits.*.is_primary' => 'nullable|boolean',
|
||||
'evolution_target_artwork_id' => 'sometimes|nullable|integer|min:1',
|
||||
'evolution_relation_type' => 'sometimes|nullable|string|in:remake_of,remaster_of,revision_of,inspired_by,variation_of',
|
||||
'evolution_note' => 'sometimes|nullable|string|max:1200',
|
||||
]);
|
||||
|
||||
$hasAttributionUpdates = array_key_exists('group', $validated)
|
||||
|| array_key_exists('primary_author_user_id', $validated)
|
||||
|| array_key_exists('contributor_user_ids', $validated)
|
||||
|| array_key_exists('contributor_credits', $validated);
|
||||
$hasEvolutionUpdates = array_key_exists('evolution_target_artwork_id', $validated)
|
||||
|| array_key_exists('evolution_relation_type', $validated)
|
||||
|| array_key_exists('evolution_note', $validated);
|
||||
|
||||
$attributionPayload = [
|
||||
'group' => $validated['group'] ?? $artwork->group?->slug,
|
||||
'primary_author_user_id' => $validated['primary_author_user_id'] ?? $artwork->primary_author_user_id,
|
||||
'contributor_user_ids' => $validated['contributor_user_ids'] ?? $artwork->contributors()->pluck('user_id')->all(),
|
||||
'contributor_credits' => $validated['contributor_credits'] ?? $artwork->contributors()->get()->map(fn ($contributor): array => [
|
||||
'user_id' => (int) $contributor->user_id,
|
||||
'credit_role' => $contributor->credit_role,
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
])->values()->all(),
|
||||
];
|
||||
|
||||
$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;
|
||||
$contentTypeId = $validated['content_type_id'] ?? null;
|
||||
$evolutionPayload = [
|
||||
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
|
||||
'relation_type' => $validated['evolution_relation_type'] ?? null,
|
||||
'note' => $validated['evolution_note'] ?? null,
|
||||
];
|
||||
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
|
||||
|
||||
$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);
|
||||
|
||||
// Sync category
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([(int) $categoryId]);
|
||||
}
|
||||
|
||||
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
|
||||
if ($tags !== null) {
|
||||
try {
|
||||
$this->tagService->syncStudioTags(
|
||||
$artwork,
|
||||
$tags,
|
||||
(string) ($validated['tags_source'] ?? 'manual')
|
||||
);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->json(['errors' => $exception->errors()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasAttributionUpdates) {
|
||||
$artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload);
|
||||
}
|
||||
|
||||
if ($hasEvolutionUpdates) {
|
||||
try {
|
||||
$evolution->syncPrimaryRelation($artwork->fresh(['group.members']), $request->user(), $evolutionPayload);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->json(['errors' => $exception->errors()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex in Meilisearch
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Meilisearch may be unavailable
|
||||
}
|
||||
|
||||
// Reload relationships for response
|
||||
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$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,
|
||||
'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,
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
|
||||
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(),
|
||||
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
|
||||
'user_id' => (int) $contributor->user_id,
|
||||
'credit_role' => $contributor->credit_role,
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
])->values()->all(),
|
||||
'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',
|
||||
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function evolutionOptions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'search' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
$evolution = app(ArtworkEvolutionService::class);
|
||||
|
||||
return response()->json([
|
||||
'data' => $evolution->manageableSearchOptions($artwork, $request->user(), (string) ($validated['search'] ?? '')),
|
||||
'meta' => [
|
||||
'selected' => $evolution->editorRelation($artwork, $request->user()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
'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,
|
||||
'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 for studio pickers, with empty-query fallback to popular tags.
|
||||
*/
|
||||
public function searchTags(Request $request): JsonResponse
|
||||
{
|
||||
$query = trim((string) $request->input('q'));
|
||||
|
||||
$tags = $this->tagDiscoveryService->searchSuggestions($query, 30);
|
||||
|
||||
return response()->json($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/replace-file
|
||||
* Replace the artwork's primary image file — creates a new immutable version.
|
||||
*
|
||||
* Accepts an optional `change_note` text field alongside the file.
|
||||
*/
|
||||
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', // 50 MB
|
||||
'change_note' => 'sometimes|nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// ── Rate-limit gate (before expensive file processing) ────────────
|
||||
try {
|
||||
$this->versioningService->rateLimitCheck($request->user()->id, $artwork->id);
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getRealPath();
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
|
||||
// Reject identical files early (before any disk writes)
|
||||
if ($artwork->hash === $hash) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'The uploaded file is identical to the current version.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
|
||||
// 1. Store original on disk (preserve extension when possible)
|
||||
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalPath = $originalAsset['local_path'];
|
||||
$origFilename = basename($originalPath);
|
||||
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
|
||||
|
||||
// 2. Generate thumbnails (xs/sm/md/lg/xl)
|
||||
$publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAssets as $variant => $asset) {
|
||||
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
|
||||
}
|
||||
|
||||
// 3. Get new dimensions
|
||||
$dims = @getimagesize($tempPath);
|
||||
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
|
||||
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
|
||||
$size = (int) filesize($originalPath);
|
||||
|
||||
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
|
||||
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
|
||||
$displayFileName = $origFilename;
|
||||
|
||||
$clientName = basename(str_replace('\\', '/', (string) $file->getClientOriginalName()));
|
||||
$clientName = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $clientName) ?? '';
|
||||
$clientName = trim((string) $clientName);
|
||||
|
||||
if ($clientName !== '') {
|
||||
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
|
||||
if ($clientExt === '' && $origExt !== '') {
|
||||
$clientName .= '.' . $origExt;
|
||||
}
|
||||
|
||||
$displayFileName = $clientName;
|
||||
}
|
||||
|
||||
$artwork->update([
|
||||
'file_name' => $displayFileName,
|
||||
'file_path' => '',
|
||||
'file_size' => $size,
|
||||
'mime_type' => $origMime,
|
||||
'hash' => $hash,
|
||||
'file_ext' => $origExt,
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
]);
|
||||
|
||||
// 5. Create version record, apply ranking protection, audit log
|
||||
$version = $this->versioningService->createNewVersion(
|
||||
$artwork,
|
||||
$originalRelative,
|
||||
$hash,
|
||||
max(1, $width),
|
||||
max(1, $height),
|
||||
$size,
|
||||
$request->user()->id,
|
||||
$request->input('change_note'),
|
||||
);
|
||||
|
||||
// 6. Reindex in Meilisearch (non-blocking)
|
||||
try {
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. CDN cache bust — purge thumbnail paths for the old hash
|
||||
$this->purgeCdnCache($artwork, $hash);
|
||||
|
||||
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,
|
||||
'version_number' => $version->version_number,
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('replaceFile: processing error', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/studio/artworks/{id}/versions
|
||||
* Return version history for an artwork (newest first).
|
||||
*/
|
||||
public function versions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
],
|
||||
'versions' => $versions->map(fn (ArtworkVersion $v) => [
|
||||
'id' => $v->id,
|
||||
'version_number' => $v->version_number,
|
||||
'file_path' => $v->file_path,
|
||||
'file_hash' => $v->file_hash,
|
||||
'width' => $v->width,
|
||||
'height' => $v->height,
|
||||
'file_size' => $v->file_size,
|
||||
'change_note' => $v->change_note,
|
||||
'is_current' => $v->is_current,
|
||||
'created_at' => $v->created_at?->toIso8601String(),
|
||||
])->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/restore/{version_id}
|
||||
* Restore an earlier version (cloned as a new current version).
|
||||
*/
|
||||
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$version = ArtworkVersion::where('artwork_id', $artwork->id)->findOrFail($versionId);
|
||||
|
||||
if ($version->is_current) {
|
||||
return response()->json(['success' => false, 'error' => 'This version is already the current version.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$newVersion = $this->versioningService->restoreVersion($version, $artwork, $request->user()->id);
|
||||
|
||||
// Sync artwork file fields back to restored version dimensions
|
||||
$artwork->update([
|
||||
'width' => max(1, (int) $version->width),
|
||||
'height' => max(1, (int) $version->height),
|
||||
'file_size' => (int) $version->file_size,
|
||||
]);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
// Reindex
|
||||
try {
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'version_number' => $newVersion->version_number,
|
||||
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
|
||||
]);
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge CDN thumbnail cache for the artwork.
|
||||
*
|
||||
* This is best-effort; failures are logged but never fatal.
|
||||
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
|
||||
*/
|
||||
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
|
||||
{
|
||||
try {
|
||||
$this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
|
||||
'artwork_id' => $artwork->id,
|
||||
'reason' => 'artwork_file_replaced',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Studio\CreatorStudioCommentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StudioCommentsApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioCommentService $comments,
|
||||
) {
|
||||
}
|
||||
|
||||
public function reply(Request $request, string $module, int $commentId): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:10000'],
|
||||
]);
|
||||
|
||||
$this->comments->reply($request->user(), $module, $commentId, (string) $payload['content']);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function moderate(Request $request, string $module, int $commentId): JsonResponse
|
||||
{
|
||||
$this->comments->moderate($request->user(), $module, $commentId);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function report(Request $request, string $module, int $commentId): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'reason' => ['required', 'string', 'max:120'],
|
||||
'details' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'report' => $this->comments->report(
|
||||
$request->user(),
|
||||
$module,
|
||||
$commentId,
|
||||
(string) $payload['reason'],
|
||||
isset($payload['details']) ? (string) $payload['details'] : null,
|
||||
),
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Group;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\GroupMembershipService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\Studio\CreatorStudioAnalyticsService;
|
||||
use App\Services\Studio\CreatorStudioAssetService;
|
||||
use App\Services\Studio\CreatorStudioCalendarService;
|
||||
use App\Services\Studio\CreatorStudioCommentService;
|
||||
use App\Services\Studio\CreatorStudioContentService;
|
||||
use App\Services\Studio\CreatorStudioFollowersService;
|
||||
use App\Services\Studio\CreatorStudioGrowthService;
|
||||
use App\Services\Studio\CreatorStudioActivityService;
|
||||
use App\Services\Studio\CreatorStudioInboxService;
|
||||
use App\Services\Studio\CreatorStudioOverviewService;
|
||||
use App\Services\Studio\CreatorStudioPreferenceService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Studio\CreatorStudioScheduledService;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Serves Studio Inertia pages for authenticated creators.
|
||||
*/
|
||||
final class StudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioOverviewService $overview,
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioAnalyticsService $analytics,
|
||||
private readonly CreatorStudioFollowersService $followers,
|
||||
private readonly CreatorStudioCommentService $comments,
|
||||
private readonly CreatorStudioAssetService $assets,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
private readonly CreatorStudioScheduledService $scheduled,
|
||||
private readonly CreatorStudioActivityService $activity,
|
||||
private readonly CreatorStudioCalendarService $calendar,
|
||||
private readonly CreatorStudioInboxService $inbox,
|
||||
private readonly CreatorStudioSearchService $search,
|
||||
private readonly CreatorStudioChallengeService $challenges,
|
||||
private readonly CreatorStudioGrowthService $growth,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Studio Overview Dashboard (/studio)
|
||||
*/
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$prefs = $this->preferences->forUser($user);
|
||||
|
||||
if (! $request->boolean('overview') && $prefs['default_landing_page'] !== 'overview') {
|
||||
return redirect()->route($this->landingPageRoute($prefs['default_landing_page']), $request->query(), 302);
|
||||
}
|
||||
|
||||
return Inertia::render('Studio/StudioDashboard', [
|
||||
'overview' => $this->overview->build($user),
|
||||
'analytics' => $this->analytics->overview($user, $prefs['analytics_range_days']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function content(Request $request): Response
|
||||
{
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioContentIndex', [
|
||||
'title' => 'Content',
|
||||
'description' => 'Manage every artwork, card, collection, and story from one queue.',
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork Manager (/studio/artworks)
|
||||
*/
|
||||
public function artworks(Request $request): Response
|
||||
{
|
||||
$provider = $this->content->provider('artworks');
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'category', 'tag']), null, 'artworks');
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioArtworks', [
|
||||
'title' => 'Artworks',
|
||||
'description' => 'Upload, manage, and review long-form visual work from the shared Creator Studio workflow.',
|
||||
'summary' => $provider?->summary($request->user()),
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drafts (/studio/drafts)
|
||||
*/
|
||||
public function drafts(Request $request): Response
|
||||
{
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['module', 'q', 'sort', 'page', 'per_page', 'stale']), 'drafts');
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioDrafts', [
|
||||
'title' => 'Drafts',
|
||||
'description' => 'Resume unfinished work across every creator module.',
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived (/studio/archived)
|
||||
*/
|
||||
public function archived(Request $request): Response
|
||||
{
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['module', 'q', 'sort', 'page', 'per_page']), 'archived');
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioArchived', [
|
||||
'title' => 'Archived',
|
||||
'description' => 'Review hidden, rejected, and archived content in one place.',
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function scheduled(Request $request): Response
|
||||
{
|
||||
$listing = $this->scheduled->list($request->user(), $request->only(['module', 'q', 'page', 'per_page', 'range', 'start_date', 'end_date']));
|
||||
|
||||
return Inertia::render('Studio/StudioScheduled', [
|
||||
'title' => 'Scheduled',
|
||||
'description' => 'Keep track of upcoming publishes across artworks, cards, collections, and stories.',
|
||||
'listing' => $listing,
|
||||
'endpoints' => [
|
||||
'publishNowPattern' => route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']),
|
||||
'unschedulePattern' => route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function calendar(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioCalendar', [
|
||||
'title' => 'Calendar',
|
||||
'description' => 'Plan publishing cadence, spot overloaded days, and move quickly between scheduled work and the unscheduled queue.',
|
||||
'calendar' => $this->calendar->build($request->user(), $request->only(['view', 'module', 'status', 'q', 'focus_date'])),
|
||||
'endpoints' => [
|
||||
'publishNowPattern' => route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']),
|
||||
'unschedulePattern' => route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function collections(Request $request): Response
|
||||
{
|
||||
$provider = $this->content->provider('collections');
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'page', 'per_page', 'visibility']), null, 'collections');
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioCollections', [
|
||||
'title' => 'Collections',
|
||||
'description' => 'Curate sets, track collection performance, and keep editorial surfaces organised.',
|
||||
'summary' => $provider?->summary($request->user()),
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
'dashboardUrl' => route('settings.collections.dashboard'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function stories(Request $request): Response
|
||||
{
|
||||
$provider = $this->content->provider('stories');
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'page', 'per_page', 'activity_state']), null, 'stories');
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioStories', [
|
||||
'title' => 'Stories',
|
||||
'description' => 'Track drafts, jump into the editor, and monitor story reach from Studio.',
|
||||
'summary' => $provider?->summary($request->user()),
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
'dashboardUrl' => route('creator.stories.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function assets(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioAssets', [
|
||||
'title' => 'Assets',
|
||||
'description' => 'A reusable creator asset library for card backgrounds, story covers, collection covers, artwork previews, and profile branding.',
|
||||
'assets' => $this->assets->library($request->user(), $request->only(['type', 'source', 'sort', 'q', 'page', 'per_page'])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function comments(Request $request): Response
|
||||
{
|
||||
$listing = $this->comments->list($request->user(), $request->only(['module', 'q', 'page', 'per_page']));
|
||||
|
||||
return Inertia::render('Studio/StudioComments', [
|
||||
'title' => 'Comments',
|
||||
'description' => 'View context, reply in place, remove unsafe comments, and report issues across all of your content.',
|
||||
'listing' => $listing,
|
||||
'endpoints' => [
|
||||
'replyPattern' => route('api.studio.comments.reply', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
|
||||
'moderatePattern' => route('api.studio.comments.moderate', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
|
||||
'reportPattern' => route('api.studio.comments.report', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function followers(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioFollowers', [
|
||||
'title' => 'Followers',
|
||||
'description' => 'See who is following your work, who follows back, and which supporters are most established.',
|
||||
'listing' => $this->followers->list($request->user(), $request->only(['q', 'sort', 'relationship', 'page'])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function activity(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioActivity', [
|
||||
'title' => 'Activity',
|
||||
'description' => 'One creator-facing inbox for notifications, comments, and follower activity.',
|
||||
'listing' => $this->activity->list($request->user(), $request->only(['type', 'module', 'q', 'page', 'per_page'])),
|
||||
'endpoints' => [
|
||||
'markAllRead' => route('api.studio.activity.readAll'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function inbox(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioInbox', [
|
||||
'title' => 'Inbox',
|
||||
'description' => 'A creator-first response surface for comments, notifications, followers, reminders, and what needs attention now.',
|
||||
'inbox' => $this->inbox->build($request->user(), $request->only(['type', 'module', 'q', 'page', 'per_page', 'read_state', 'priority'])),
|
||||
'endpoints' => [
|
||||
'markAllRead' => route('api.studio.activity.readAll'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioSearch', [
|
||||
'title' => 'Search',
|
||||
'description' => 'Search across content, comments, inbox signals, and reusable assets without leaving Creator Studio.',
|
||||
'search' => $this->search->build($request->user(), $request->only(['q', 'module', 'type'])),
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function challenges(Request $request): Response
|
||||
{
|
||||
$data = $this->challenges->build($request->user());
|
||||
|
||||
return Inertia::render('Studio/StudioChallenges', [
|
||||
'title' => 'Challenges',
|
||||
'description' => 'Track active Nova Cards challenge runs, review your submissions, and keep challenge-ready cards close to hand.',
|
||||
'summary' => $data['summary'],
|
||||
'spotlight' => $data['spotlight'],
|
||||
'activeChallenges' => $data['active_challenges'],
|
||||
'recentEntries' => $data['recent_entries'],
|
||||
'cardLeaders' => $data['card_leaders'],
|
||||
'reminders' => $data['reminders'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function growth(Request $request): Response
|
||||
{
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$rangeDays = in_array((int) $request->query('range_days', 0), [7, 14, 30, 60, 90], true)
|
||||
? (int) $request->query('range_days')
|
||||
: $prefs['analytics_range_days'];
|
||||
$data = $this->growth->build($request->user(), $rangeDays);
|
||||
|
||||
return Inertia::render('Studio/StudioGrowth', [
|
||||
'title' => 'Growth',
|
||||
'description' => 'A creator-readable view of profile readiness, publishing cadence, engagement momentum, and challenge participation.',
|
||||
'summary' => $data['summary'],
|
||||
'moduleFocus' => $data['module_focus'],
|
||||
'checkpoints' => $data['checkpoints'],
|
||||
'opportunities' => $data['opportunities'],
|
||||
'milestones' => $data['milestones'],
|
||||
'momentum' => $data['momentum'],
|
||||
'topContent' => $data['top_content'],
|
||||
'rangeDays' => $data['range_days'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function profile(Request $request): Response
|
||||
{
|
||||
$user = $request->user()->loadMissing(['profile', 'statistics']);
|
||||
$prefs = $this->preferences->forUser($user);
|
||||
$socialLinks = DB::table('user_social_links')
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('platform')
|
||||
->get(['platform', 'url'])
|
||||
->map(fn ($row): array => [
|
||||
'platform' => (string) $row->platform,
|
||||
'url' => (string) $row->url,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('Studio/StudioProfile', [
|
||||
'title' => 'Profile',
|
||||
'description' => 'Keep your public creator presence aligned with the work you are publishing.',
|
||||
'profile' => [
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'bio' => $user->profile?->about,
|
||||
'tagline' => $user->profile?->description,
|
||||
'location' => $user->profile?->country,
|
||||
'website' => $user->profile?->website,
|
||||
'avatar_url' => $user->profile?->avatar_url,
|
||||
'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null,
|
||||
'cover_position' => (int) ($user->cover_position ?? 50),
|
||||
'followers' => (int) ($user->statistics?->followers_count ?? 0),
|
||||
'profile_url' => '/@' . strtolower((string) $user->username),
|
||||
'social_links' => $socialLinks,
|
||||
],
|
||||
'moduleSummaries' => $this->content->moduleSummaries($user),
|
||||
'featuredModules' => $prefs['featured_modules'],
|
||||
'featuredContent' => $this->content->selectedItems($user, $prefs['featured_content']),
|
||||
'endpoints' => [
|
||||
'profile' => route('api.studio.preferences.profile'),
|
||||
'avatarUpload' => route('avatar.upload'),
|
||||
'coverUpload' => route('api.profile.cover.upload'),
|
||||
'coverPosition' => route('api.profile.cover.position'),
|
||||
'coverDelete' => route('api.profile.cover.destroy'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function featured(Request $request): Response
|
||||
{
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
|
||||
return Inertia::render('Studio/StudioFeatured', [
|
||||
'title' => 'Featured',
|
||||
'description' => 'Choose the artwork, card, collection, and story that should represent each module on your public profile.',
|
||||
'items' => $this->content->featuredCandidates($request->user(), 12),
|
||||
'selected' => $prefs['featured_content'],
|
||||
'featuredModules' => $prefs['featured_modules'],
|
||||
'endpoints' => [
|
||||
'save' => route('api.studio.preferences.featured'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioSettings', [
|
||||
'title' => 'Settings',
|
||||
'description' => 'Keep system handoff links, legacy dashboards, and future Studio control surfaces organized in one place.',
|
||||
'links' => [
|
||||
['label' => 'Profile settings', 'url' => route('settings.profile'), 'icon' => 'fa-solid fa-user-gear'],
|
||||
['label' => 'Collection dashboard', 'url' => route('settings.collections.dashboard'), 'icon' => 'fa-solid fa-layer-group'],
|
||||
['label' => 'Story dashboard', 'url' => route('creator.stories.index'), 'icon' => 'fa-solid fa-feather-pointed'],
|
||||
['label' => 'Followers', 'url' => route('dashboard.followers'), 'icon' => 'fa-solid fa-user-group'],
|
||||
['label' => 'Received comments', 'url' => route('dashboard.comments.received'), 'icon' => 'fa-solid fa-comments'],
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Studio preferences moved into their own surface',
|
||||
'body' => 'Use the dedicated Preferences page for layout, landing page, analytics window, widget order, and shortcut controls.',
|
||||
'href' => route('studio.preferences'),
|
||||
'cta' => 'Open preferences',
|
||||
],
|
||||
[
|
||||
'title' => 'Future-ready control points',
|
||||
'body' => 'Notification routing, automation defaults, and collaboration hooks can plug into this settings surface without overloading creator workflow pages.',
|
||||
'href' => route('studio.growth'),
|
||||
'cta' => 'Review growth',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function preferences(Request $request): Response
|
||||
{
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
|
||||
return Inertia::render('Studio/StudioPreferences', [
|
||||
'title' => 'Preferences',
|
||||
'description' => 'Control how Creator Studio opens, which widgets stay visible, and where your daily workflow starts.',
|
||||
'preferences' => $prefs,
|
||||
'links' => [
|
||||
['label' => 'Profile settings', 'url' => route('settings.profile'), 'icon' => 'fa-solid fa-user-gear'],
|
||||
['label' => 'Featured content', 'url' => route('studio.featured'), 'icon' => 'fa-solid fa-wand-magic-sparkles'],
|
||||
['label' => 'Growth overview', 'url' => route('studio.growth'), 'icon' => 'fa-solid fa-chart-line'],
|
||||
['label' => 'Studio settings', 'url' => route('studio.settings'), 'icon' => 'fa-solid fa-sliders'],
|
||||
],
|
||||
'endpoints' => [
|
||||
'save' => route('api.studio.preferences.settings'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit artwork (/studio/artworks/:id/edit)
|
||||
*/
|
||||
public function edit(Request $request, int $id): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = $user->artworks()
|
||||
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
|
||||
->findOrFail($id);
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$availableGroups = app(GroupService::class)->studioOptionsForUser($user);
|
||||
$membershipService = app(GroupMembershipService::class);
|
||||
$contributorOptionsByGroup = [];
|
||||
|
||||
foreach ($availableGroups as $groupOption) {
|
||||
$group = Group::query()->with('members')->where('slug', (string) ($groupOption['slug'] ?? ''))->first();
|
||||
if (! $group || ! $group->hasActiveMember($user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contributorOptionsByGroup[(string) $group->slug] = $membershipService->contributorOptions($group);
|
||||
}
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkEdit', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'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,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
|
||||
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(),
|
||||
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
|
||||
'user_id' => (int) $contributor->user_id,
|
||||
'credit_role' => $contributor->credit_role,
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
])->values()->all(),
|
||||
'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(),
|
||||
'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',
|
||||
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
|
||||
// Versioning
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'contentTypes' => $this->getCategories(),
|
||||
'groupOptions' => $availableGroups,
|
||||
'contributorOptionsByGroup' => $contributorOptionsByGroup,
|
||||
'evolutionRelationTypes' => app(ArtworkEvolutionService::class)->relationTypeOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$user = $request->user();
|
||||
$prefs = $this->preferences->forUser($user);
|
||||
$rangeDays = in_array((int) $request->query('range_days', 0), [7, 14, 30, 60, 90], true)
|
||||
? (int) $request->query('range_days')
|
||||
: $prefs['analytics_range_days'];
|
||||
$data = $this->analytics->overview($user, $rangeDays);
|
||||
|
||||
return Inertia::render('Studio/StudioAnalytics', [
|
||||
'totals' => $data['totals'],
|
||||
'topContent' => $data['top_content'],
|
||||
'moduleBreakdown' => $data['module_breakdown'],
|
||||
'viewsTrend' => $data['views_trend'],
|
||||
'engagementTrend' => $data['engagement_trend'],
|
||||
'publishingTimeline' => $data['publishing_timeline'],
|
||||
'comparison' => $data['comparison'],
|
||||
'insightBlocks' => $data['insight_blocks'],
|
||||
'rangeDays' => $data['range_days'],
|
||||
'recentComments' => $this->overview->recentComments($user, 8),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCategories(): array
|
||||
{
|
||||
return ContentType::with(['rootCategories.children'])->ordered()->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();
|
||||
}
|
||||
|
||||
private function landingPageRoute(string $page): string
|
||||
{
|
||||
return match ($page) {
|
||||
'content' => 'studio.content',
|
||||
'drafts' => 'studio.drafts',
|
||||
'scheduled' => 'studio.scheduled',
|
||||
'analytics' => 'studio.analytics',
|
||||
'activity' => 'studio.activity',
|
||||
'calendar' => 'studio.calendar',
|
||||
'inbox' => 'studio.inbox',
|
||||
'search' => 'studio.search',
|
||||
'growth' => 'studio.growth',
|
||||
'challenges' => 'studio.challenges',
|
||||
'preferences' => 'studio.preferences',
|
||||
default => 'studio.index',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Studio\CreatorStudioEventService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class StudioEventsApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioEventService $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'event_type' => ['required', 'string', Rule::in($this->events->allowedEvents())],
|
||||
'module' => ['sometimes', 'nullable', 'string', 'max:40'],
|
||||
'surface' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||
'item_module' => ['sometimes', 'nullable', 'string', 'max:40'],
|
||||
'item_id' => ['sometimes', 'nullable', 'integer'],
|
||||
'meta' => ['sometimes', 'array'],
|
||||
]);
|
||||
|
||||
$this->events->record($request->user(), $payload);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
], 202);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\News\NewsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsCategory;
|
||||
use cPad\Plugins\News\Models\NewsTag;
|
||||
|
||||
final class StudioNewsController extends Controller
|
||||
{
|
||||
public function __construct(private readonly NewsService $news)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
return Inertia::render('Studio/StudioNewsIndex', [
|
||||
'title' => 'Newsroom',
|
||||
'description' => 'Plan announcements, publish editorial stories, and connect articles to the rest of Nova.',
|
||||
'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page'])),
|
||||
'statusOptions' => $this->news->editorialStatusOptions(),
|
||||
'typeOptions' => $this->news->articleTypeOptions(),
|
||||
'categoryOptions' => $this->news->categoryOptions(),
|
||||
'createUrl' => route('studio.news.create'),
|
||||
'categoriesUrl' => route('studio.news.categories'),
|
||||
'tagsUrl' => route('studio.news.tags'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
return Inertia::render('Studio/StudioNewsEditor', [
|
||||
'title' => 'Create article',
|
||||
'description' => 'Draft a new News story with editorial workflow, SEO metadata, and related entity links.',
|
||||
'article' => null,
|
||||
'typeOptions' => $this->news->articleTypeOptions(),
|
||||
'statusOptions' => $this->news->editorialStatusOptions(),
|
||||
'categoryOptions' => $this->news->categoryOptions(),
|
||||
'tagOptions' => $this->news->tagOptions(),
|
||||
'relationTypeOptions' => $this->news->relationTypeOptions(),
|
||||
'storeUrl' => route('studio.news.store'),
|
||||
'entitySearchUrl' => route('studio.news.entity-search'),
|
||||
'categoriesUrl' => route('studio.news.categories'),
|
||||
'tagsUrl' => route('studio.news.tags'),
|
||||
'defaultAuthor' => $this->news->searchEntities('user', (string) $request->user()->username)[0] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$article = $this->news->storeArticle($request->user(), $this->validateArticle($request));
|
||||
|
||||
return redirect()->route('studio.news.edit', ['article' => $article->id])->with('success', 'Article draft created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, NewsArticle $article): Response
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
return Inertia::render('Studio/StudioNewsEditor', [
|
||||
'title' => 'Edit article',
|
||||
'description' => 'Refine the story, tune SEO, and attach related Nova entities before publishing.',
|
||||
'article' => $this->news->mapStudioArticle($article, $request->user()),
|
||||
'typeOptions' => $this->news->articleTypeOptions(),
|
||||
'statusOptions' => $this->news->editorialStatusOptions(),
|
||||
'categoryOptions' => $this->news->categoryOptions(),
|
||||
'tagOptions' => $this->news->tagOptions(),
|
||||
'relationTypeOptions' => $this->news->relationTypeOptions(),
|
||||
'updateUrl' => route('studio.news.update', ['article' => $article->id]),
|
||||
'previewUrl' => route('studio.news.preview', ['article' => $article->id]),
|
||||
'publishUrl' => route('studio.news.publish', ['article' => $article->id]),
|
||||
'archiveUrl' => route('studio.news.archive', ['article' => $article->id]),
|
||||
'featureUrl' => route('studio.news.feature', ['article' => $article->id]),
|
||||
'pinUrl' => route('studio.news.pin', ['article' => $article->id]),
|
||||
'entitySearchUrl' => route('studio.news.entity-search'),
|
||||
'categoriesUrl' => route('studio.news.categories'),
|
||||
'tagsUrl' => route('studio.news.tags'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(Request $request, NewsArticle $article): View
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$article->loadMissing(['author.profile', 'category', 'tags', 'relatedEntities']);
|
||||
|
||||
$related = NewsArticle::with('author', 'category')
|
||||
->published()
|
||||
->when($article->category_id, fn ($query) => $query->where('category_id', $article->category_id))
|
||||
->where('id', '!=', $article->id)
|
||||
->editorialOrder()
|
||||
->limit(config('news.related_limit', 4))
|
||||
->get();
|
||||
|
||||
return view('news.show', [
|
||||
'article' => $article,
|
||||
'related' => $related,
|
||||
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
|
||||
'previewMode' => true,
|
||||
'previewCanonical' => route('studio.news.preview', ['article' => $article->id]),
|
||||
'previewBackUrl' => route('studio.news.edit', ['article' => $article->id]),
|
||||
] + $this->news->sidebarData());
|
||||
}
|
||||
|
||||
public function update(Request $request, NewsArticle $article): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$this->news->updateArticle($article, $request->user(), $this->validateArticle($request, $article));
|
||||
|
||||
return back()->with('success', 'Article updated.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, NewsArticle $article): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$this->news->publish($article);
|
||||
|
||||
return back()->with('success', 'Article published.');
|
||||
}
|
||||
|
||||
public function archive(Request $request, NewsArticle $article): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$this->news->archive($article);
|
||||
|
||||
return back()->with('success', 'Article archived.');
|
||||
}
|
||||
|
||||
public function feature(Request $request, NewsArticle $article): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$updated = $this->news->toggleFeature($article);
|
||||
|
||||
return back()->with('success', $updated->is_featured ? 'Article featured.' : 'Article removed from featured surface.');
|
||||
}
|
||||
|
||||
public function pin(Request $request, NewsArticle $article): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$updated = $this->news->togglePin($article);
|
||||
|
||||
return back()->with('success', $updated->is_pinned ? 'Article pinned.' : 'Article unpinned.');
|
||||
}
|
||||
|
||||
public function categories(Request $request): Response
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
return Inertia::render('Studio/StudioNewsTaxonomies', [
|
||||
'title' => 'News taxonomies',
|
||||
'description' => 'Manage News categories and tags used across the editorial surface.',
|
||||
'activeTab' => 'categories',
|
||||
'categories' => NewsCategory::query()
|
||||
->withCount('publishedArticles')
|
||||
->ordered()
|
||||
->get()
|
||||
->map(fn (NewsCategory $category): array => [
|
||||
'id' => (int) $category->id,
|
||||
'name' => (string) $category->name,
|
||||
'slug' => (string) $category->slug,
|
||||
'description' => (string) ($category->description ?? ''),
|
||||
'position' => (int) $category->position,
|
||||
'is_active' => (bool) $category->is_active,
|
||||
'published_count' => (int) $category->published_articles_count,
|
||||
])
|
||||
->all(),
|
||||
'tags' => $this->tagPayload(),
|
||||
'storeCategoryUrl' => route('studio.news.categories.store'),
|
||||
'storeTagUrl' => route('studio.news.tags.store'),
|
||||
'updateCategoryUrlPattern' => route('studio.news.categories.update', ['category' => '__CATEGORY__']),
|
||||
'updateTagUrlPattern' => route('studio.news.tags.update', ['tag' => '__TAG__']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tags(Request $request): Response
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
return Inertia::render('Studio/StudioNewsTaxonomies', [
|
||||
'title' => 'News taxonomies',
|
||||
'description' => 'Manage News categories and tags used across the editorial surface.',
|
||||
'activeTab' => 'tags',
|
||||
'categories' => NewsCategory::query()
|
||||
->withCount('publishedArticles')
|
||||
->ordered()
|
||||
->get()
|
||||
->map(fn (NewsCategory $category): array => [
|
||||
'id' => (int) $category->id,
|
||||
'name' => (string) $category->name,
|
||||
'slug' => (string) $category->slug,
|
||||
'description' => (string) ($category->description ?? ''),
|
||||
'position' => (int) $category->position,
|
||||
'is_active' => (bool) $category->is_active,
|
||||
'published_count' => (int) $category->published_articles_count,
|
||||
])
|
||||
->all(),
|
||||
'tags' => $this->tagPayload(),
|
||||
'storeCategoryUrl' => route('studio.news.categories.store'),
|
||||
'storeTagUrl' => route('studio.news.tags.store'),
|
||||
'updateCategoryUrlPattern' => route('studio.news.categories.update', ['category' => '__CATEGORY__']),
|
||||
'updateTagUrlPattern' => route('studio.news.tags.update', ['tag' => '__TAG__']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeCategory(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120', 'unique:news_categories,name'],
|
||||
'slug' => ['nullable', 'string', 'max:120', 'unique:news_categories,slug'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'position' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
NewsCategory::query()->create([
|
||||
'name' => trim((string) $validated['name']),
|
||||
'slug' => NewsCategory::generateUniqueSlug((string) ($validated['slug'] ?? $validated['name'])),
|
||||
'description' => $validated['description'] ?? null,
|
||||
'position' => (int) ($validated['position'] ?? 0),
|
||||
'is_active' => (bool) ($validated['is_active'] ?? true),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Category created.');
|
||||
}
|
||||
|
||||
public function updateCategory(Request $request, NewsCategory $category): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120', Rule::unique('news_categories', 'name')->ignore($category->id)],
|
||||
'slug' => ['nullable', 'string', 'max:120', Rule::unique('news_categories', 'slug')->ignore($category->id)],
|
||||
'description' => ['nullable', 'string'],
|
||||
'position' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$category->update([
|
||||
'name' => trim((string) $validated['name']),
|
||||
'slug' => NewsCategory::generateUniqueSlug((string) ($validated['slug'] ?? $validated['name']), (int) $category->id),
|
||||
'description' => $validated['description'] ?? null,
|
||||
'position' => (int) ($validated['position'] ?? 0),
|
||||
'is_active' => (bool) ($validated['is_active'] ?? true),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Category updated.');
|
||||
}
|
||||
|
||||
public function storeTag(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:80', 'unique:news_tags,name'],
|
||||
'slug' => ['nullable', 'string', 'max:80', 'unique:news_tags,slug'],
|
||||
]);
|
||||
|
||||
NewsTag::query()->create([
|
||||
'name' => trim((string) $validated['name']),
|
||||
'slug' => $this->uniqueTagSlug((string) ($validated['slug'] ?? $validated['name'])),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Tag created.');
|
||||
}
|
||||
|
||||
public function updateTag(Request $request, NewsTag $tag): RedirectResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:80', Rule::unique('news_tags', 'name')->ignore($tag->id)],
|
||||
'slug' => ['nullable', 'string', 'max:80', Rule::unique('news_tags', 'slug')->ignore($tag->id)],
|
||||
]);
|
||||
|
||||
$tag->update([
|
||||
'name' => trim((string) $validated['name']),
|
||||
'slug' => $this->uniqueTagSlug((string) ($validated['slug'] ?? $validated['name']), (int) $tag->id),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Tag updated.');
|
||||
}
|
||||
|
||||
public function entitySearch(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeNews($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => ['required', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))],
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'items' => $this->news->searchEntities((string) $validated['type'], (string) ($validated['q'] ?? ''), $request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeNews(Request $request): void
|
||||
{
|
||||
abort_unless($request->user() && ($request->user()->isAdmin() || $request->user()->isModerator()), 403);
|
||||
}
|
||||
|
||||
private function validateArticle(Request $request, ?NewsArticle $article = null): array
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255'],
|
||||
'excerpt' => ['nullable', 'string', 'max:800'],
|
||||
'content' => ['required', 'string', 'max:50000'],
|
||||
'cover_image' => ['nullable', 'string', 'max:2048'],
|
||||
'type' => ['required', Rule::in(array_column($this->news->articleTypeOptions(), 'value'))],
|
||||
'category_id' => ['nullable', 'integer', 'exists:news_categories,id'],
|
||||
'author_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'editorial_status' => ['required', Rule::in(array_column($this->news->editorialStatusOptions(), 'value'))],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
'is_pinned' => ['nullable', 'boolean'],
|
||||
'tag_ids' => ['nullable', 'array'],
|
||||
'tag_ids.*' => ['integer', 'exists:news_tags,id'],
|
||||
'meta_title' => ['nullable', 'string', 'max:255'],
|
||||
'meta_description' => ['nullable', 'string', 'max:300'],
|
||||
'meta_keywords' => ['nullable', 'string', 'max:255'],
|
||||
'canonical_url' => ['nullable', 'url', 'max:2048'],
|
||||
'og_title' => ['nullable', 'string', 'max:255'],
|
||||
'og_description' => ['nullable', 'string', 'max:300'],
|
||||
'og_image' => ['nullable', 'string', 'max:2048'],
|
||||
'relations' => ['nullable', 'array', 'max:12'],
|
||||
'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))],
|
||||
'relations.*.entity_id' => ['required_with:relations', 'integer', 'min:1'],
|
||||
'relations.*.context_label' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'published_at' => 'Scheduled articles need a publish date and time.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
private function tagPayload(): array
|
||||
{
|
||||
return NewsTag::query()
|
||||
->withCount(['articles' => fn ($query) => $query->published()])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (NewsTag $tag): array => [
|
||||
'id' => (int) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'slug' => (string) $tag->slug,
|
||||
'published_count' => (int) $tag->articles_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function uniqueTagSlug(string $source, ?int $ignoreId = null): string
|
||||
{
|
||||
$base = Str::slug($source);
|
||||
$slug = $base !== '' ? $base : 'tag';
|
||||
$counter = 1;
|
||||
|
||||
$query = NewsTag::query()->where('slug', $slug);
|
||||
if ($ignoreId !== null) {
|
||||
$query->where('id', '!=', $ignoreId);
|
||||
}
|
||||
|
||||
while ($query->exists()) {
|
||||
$slug = ($base !== '' ? $base : 'tag') . '-' . $counter++;
|
||||
$query = NewsTag::query()->where('slug', $slug);
|
||||
if ($ignoreId !== null) {
|
||||
$query->where('id', '!=', $ignoreId);
|
||||
}
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?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 App\Services\Studio\CreatorStudioContentService;
|
||||
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,
|
||||
private readonly CreatorStudioContentService $content,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$provider = $this->content->provider('cards');
|
||||
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page']), null, 'cards');
|
||||
|
||||
return Inertia::render('Studio/StudioCardsIndex', [
|
||||
'title' => 'Cards',
|
||||
'description' => 'Manage short-form Nova cards with the same shared filters, statuses, and actions used across Creator Studio.',
|
||||
'summary' => $provider?->summary($request->user()),
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
'publicBrowseUrl' => '/cards',
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\UserProfile;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Studio\CreatorStudioPreferenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class StudioPreferencesApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
private readonly NotificationService $notifications,
|
||||
) {
|
||||
}
|
||||
|
||||
public function updatePreferences(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'default_content_view' => ['required', Rule::in(['grid', 'list'])],
|
||||
'analytics_range_days' => ['required', Rule::in([7, 14, 30, 60, 90])],
|
||||
'dashboard_shortcuts' => ['required', 'array', 'max:8'],
|
||||
'dashboard_shortcuts.*' => ['string'],
|
||||
'draft_behavior' => ['required', Rule::in(['resume-last', 'open-drafts', 'focus-published'])],
|
||||
'featured_modules' => ['nullable', 'array'],
|
||||
'featured_modules.*' => [Rule::in(['artworks', 'cards', 'collections', 'stories'])],
|
||||
'default_landing_page' => ['nullable', Rule::in(['overview', 'content', 'drafts', 'scheduled', 'analytics', 'activity', 'calendar', 'inbox', 'search', 'growth', 'challenges', 'preferences'])],
|
||||
'widget_visibility' => ['nullable', 'array'],
|
||||
'widget_order' => ['nullable', 'array'],
|
||||
'widget_order.*' => ['string'],
|
||||
'card_density' => ['nullable', Rule::in(['compact', 'comfortable'])],
|
||||
'scheduling_timezone' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->preferences->update($request->user(), $payload),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'display_name' => ['required', 'string', 'max:60'],
|
||||
'tagline' => ['nullable', 'string', 'max:1000'],
|
||||
'bio' => ['nullable', 'string', 'max:1000'],
|
||||
'website' => ['nullable', 'url', 'max:255'],
|
||||
'social_links' => ['nullable', 'array', 'max:8'],
|
||||
'social_links.*.platform' => ['required_with:social_links', 'string', 'max:32'],
|
||||
'social_links.*.url' => ['required_with:social_links', 'url', 'max:255'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$user->forceFill(['name' => (string) $payload['display_name']])->save();
|
||||
|
||||
UserProfile::query()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'about' => $payload['bio'] ?? null,
|
||||
'description' => $payload['tagline'] ?? null,
|
||||
'website' => $payload['website'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
DB::table('user_social_links')->where('user_id', $user->id)->delete();
|
||||
foreach ($payload['social_links'] ?? [] as $link) {
|
||||
DB::table('user_social_links')->insert([
|
||||
'user_id' => $user->id,
|
||||
'platform' => strtolower(trim((string) $link['platform'])),
|
||||
'url' => trim((string) $link['url']),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateFeatured(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'featured_modules' => ['nullable', 'array'],
|
||||
'featured_modules.*' => [Rule::in(['artworks', 'cards', 'collections', 'stories'])],
|
||||
'featured_content' => ['nullable', 'array'],
|
||||
'featured_content.artworks' => ['nullable', 'integer', 'min:1'],
|
||||
'featured_content.cards' => ['nullable', 'integer', 'min:1'],
|
||||
'featured_content.collections' => ['nullable', 'integer', 'min:1'],
|
||||
'featured_content.stories' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->preferences->update($request->user(), $payload),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markActivityRead(Request $request): JsonResponse
|
||||
{
|
||||
$this->notifications->markAllRead($request->user());
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->preferences->update($request->user(), [
|
||||
'activity_last_read_at' => now()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Story;
|
||||
use App\Services\CollectionLifecycleService;
|
||||
use App\Services\NovaCards\NovaCardPublishService;
|
||||
use App\Services\StoryPublicationService;
|
||||
use App\Services\Studio\CreatorStudioContentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StudioScheduleApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly NovaCardPublishService $cards,
|
||||
private readonly CollectionLifecycleService $collections,
|
||||
private readonly StoryPublicationService $stories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function publishNow(Request $request, string $module, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
match ($module) {
|
||||
'artworks' => $this->publishArtworkNow($user->id, $id),
|
||||
'cards' => $this->cards->publishNow($this->card($user->id, $id)),
|
||||
'collections' => $this->publishCollectionNow($user->id, $id),
|
||||
'stories' => $this->stories->publish($this->story($user->id, $id), 'published', ['published_at' => now()]),
|
||||
default => abort(404),
|
||||
};
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'item' => $this->serializedItem($request->user(), $module, $id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unschedule(Request $request, string $module, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
match ($module) {
|
||||
'artworks' => $this->unscheduleArtwork($user->id, $id),
|
||||
'cards' => $this->cards->clearSchedule($this->card($user->id, $id)),
|
||||
'collections' => $this->unscheduleCollection($user->id, $id),
|
||||
'stories' => $this->unscheduleStory($user->id, $id),
|
||||
default => abort(404),
|
||||
};
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'item' => $this->serializedItem($request->user(), $module, $id),
|
||||
]);
|
||||
}
|
||||
|
||||
private function publishArtworkNow(int $userId, int $id): void
|
||||
{
|
||||
$artwork = Artwork::query()
|
||||
->where('user_id', $userId)
|
||||
->findOrFail($id);
|
||||
|
||||
$artwork->forceFill([
|
||||
'artwork_status' => 'published',
|
||||
'publish_at' => null,
|
||||
'artwork_timezone' => null,
|
||||
'published_at' => now(),
|
||||
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function unscheduleArtwork(int $userId, int $id): void
|
||||
{
|
||||
Artwork::query()
|
||||
->where('user_id', $userId)
|
||||
->findOrFail($id)
|
||||
->forceFill([
|
||||
'artwork_status' => 'draft',
|
||||
'publish_at' => null,
|
||||
'artwork_timezone' => null,
|
||||
'published_at' => null,
|
||||
])
|
||||
->save();
|
||||
}
|
||||
|
||||
private function publishCollectionNow(int $userId, int $id): void
|
||||
{
|
||||
$collection = Collection::query()
|
||||
->where('user_id', $userId)
|
||||
->findOrFail($id);
|
||||
|
||||
$this->collections->applyAttributes($collection, [
|
||||
'published_at' => now(),
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
]);
|
||||
}
|
||||
|
||||
private function unscheduleCollection(int $userId, int $id): void
|
||||
{
|
||||
$collection = Collection::query()
|
||||
->where('user_id', $userId)
|
||||
->findOrFail($id);
|
||||
|
||||
$this->collections->applyAttributes($collection, [
|
||||
'published_at' => null,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_DRAFT,
|
||||
]);
|
||||
}
|
||||
|
||||
private function unscheduleStory(int $userId, int $id): void
|
||||
{
|
||||
Story::query()
|
||||
->where('creator_id', $userId)
|
||||
->findOrFail($id)
|
||||
->forceFill([
|
||||
'status' => 'draft',
|
||||
'scheduled_for' => null,
|
||||
'published_at' => null,
|
||||
])
|
||||
->save();
|
||||
}
|
||||
|
||||
private function card(int $userId, int $id): NovaCard
|
||||
{
|
||||
return NovaCard::query()
|
||||
->where('user_id', $userId)
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
private function story(int $userId, int $id): Story
|
||||
{
|
||||
return Story::query()
|
||||
->where('creator_id', $userId)
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
private function serializedItem($user, string $module, int $id): ?array
|
||||
{
|
||||
return $this->content->provider($module)?->items($user, 'all', 400)
|
||||
->first(fn (array $item): bool => (int) ($item['numeric_id'] ?? 0) === $id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user