Implement creator studio and upload updates
This commit is contained in:
@@ -9,6 +9,7 @@ use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\TagService;
|
||||
use App\Services\ArtworkVersioningService;
|
||||
@@ -36,6 +37,7 @@ final class StudioArtworksApiController extends Controller
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
private readonly TagDiscoveryService $tagDiscoveryService,
|
||||
private readonly TagService $tagService,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -419,17 +421,18 @@ final class StudioArtworksApiController extends Controller
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
|
||||
// 1. Store original on disk (preserve extension when possible)
|
||||
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$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)
|
||||
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$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) filesize($absolutePath));
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
|
||||
}
|
||||
|
||||
// 3. Get new dimensions
|
||||
@@ -592,18 +595,10 @@ final class StudioArtworksApiController extends Controller
|
||||
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
|
||||
{
|
||||
try {
|
||||
$purgeUrl = config('cdn.purge_url');
|
||||
if (empty($purgeUrl)) {
|
||||
Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]);
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = array_map(
|
||||
fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp",
|
||||
['sm', 'md', 'lg', 'xl']
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]);
|
||||
$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()]);
|
||||
}
|
||||
|
||||
55
app/Http/Controllers/Studio/StudioCommentsApiController.php
Normal file
55
app/Http/Controllers/Studio/StudioCommentsApiController.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,25 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\Studio\StudioMetricsService;
|
||||
use 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;
|
||||
|
||||
@@ -18,20 +33,51 @@ use Inertia\Response;
|
||||
final class StudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StudioMetricsService $metrics,
|
||||
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
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$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', [
|
||||
'kpis' => $this->metrics->getDashboardKpis($userId),
|
||||
'topPerformers' => $this->metrics->getTopPerformers($userId, 6),
|
||||
'recentComments' => $this->metrics->getRecentComments($userId, 5),
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -40,28 +86,329 @@ final class StudioController extends Controller
|
||||
*/
|
||||
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', [
|
||||
'categories' => $this->getCategories(),
|
||||
'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/artworks/drafts)
|
||||
* 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', [
|
||||
'categories' => $this->getCategories(),
|
||||
'title' => 'Drafts',
|
||||
'description' => 'Resume unfinished work across every creator module.',
|
||||
'listing' => $listing,
|
||||
'quickCreate' => $this->content->quickCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived (/studio/artworks/archived)
|
||||
* 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', [
|
||||
'categories' => $this->getCategories(),
|
||||
'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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -151,14 +498,24 @@ final class StudioController extends Controller
|
||||
*/
|
||||
public function analyticsOverview(Request $request): Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$data = $this->metrics->getAnalyticsOverview($userId);
|
||||
$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'],
|
||||
'topArtworks' => $data['top_artworks'],
|
||||
'contentBreakdown' => $data['content_breakdown'],
|
||||
'recentComments' => $this->metrics->getRecentComments($userId, 8),
|
||||
'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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -184,4 +541,22 @@ final class StudioController extends Controller
|
||||
];
|
||||
})->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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Http/Controllers/Studio/StudioEventsApiController.php
Normal file
37
app/Http/Controllers/Studio/StudioEventsApiController.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -16,36 +17,22 @@ class StudioNovaCardsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardPresenter $presenter,
|
||||
private readonly CreatorStudioContentService $content,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$cards = NovaCard::query()
|
||||
->with(['category', 'template', 'backgroundImage', 'tags', 'user.profile'])
|
||||
->where('user_id', $request->user()->id)
|
||||
->latest('updated_at')
|
||||
->paginate(18)
|
||||
->withQueryString();
|
||||
|
||||
$baseQuery = NovaCard::query()->where('user_id', $request->user()->id);
|
||||
$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', [
|
||||
'cards' => $this->presenter->paginator($cards, false, $request->user()),
|
||||
'stats' => [
|
||||
'all' => (clone $baseQuery)->count(),
|
||||
'drafts' => (clone $baseQuery)->where('status', NovaCard::STATUS_DRAFT)->count(),
|
||||
'processing' => (clone $baseQuery)->where('status', NovaCard::STATUS_PROCESSING)->count(),
|
||||
'published' => (clone $baseQuery)->where('status', NovaCard::STATUS_PUBLISHED)->count(),
|
||||
],
|
||||
'editorOptions' => $this->presenter->options(),
|
||||
'endpoints' => [
|
||||
'create' => route('studio.cards.create'),
|
||||
'editPattern' => route('studio.cards.edit', ['id' => '__CARD__']),
|
||||
'previewPattern' => route('studio.cards.preview', ['id' => '__CARD__']),
|
||||
'analyticsPattern' => route('studio.cards.analytics', ['id' => '__CARD__']),
|
||||
'draftStore' => route('api.cards.drafts.store'),
|
||||
],
|
||||
'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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
114
app/Http/Controllers/Studio/StudioPreferencesApiController.php
Normal file
114
app/Http/Controllers/Studio/StudioPreferencesApiController.php
Normal file
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
150
app/Http/Controllers/Studio/StudioScheduleApiController.php
Normal file
150
app/Http/Controllers/Studio/StudioScheduleApiController.php
Normal file
@@ -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