Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View File

@@ -34,11 +34,12 @@ class ArtworkController extends Controller
: null;
$result = $drafts->createDraft(
(int) $user->id,
$user,
(string) $data['title'],
isset($data['description']) ? (string) $data['description'] : null,
$categoryId,
(bool) ($data['is_mature'] ?? false)
(bool) ($data['is_mature'] ?? false),
$data['group'] ?? null,
);
return response()->json([

View File

@@ -26,6 +26,13 @@ final class LeaderboardController extends Controller
);
}
public function groups(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, (string) $request->query('period', 'weekly'))
);
}
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Search;
use App\Http\Controllers\Controller;
use App\Services\GroupDiscoveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class GroupSearchController extends Controller
{
public function __construct(private readonly GroupDiscoveryService $groups) {}
public function __invoke(Request $request): JsonResponse
{
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 2) {
return response()->json(['data' => []]);
}
$perPage = min(max((int) $request->query('per_page', 6), 1), 12);
$items = array_map(function (array $group): array {
$group['group_type'] = $group['type'] ?? null;
$group['type'] = 'group';
return $group;
}, $this->groups->searchCards($q, $request->user(), $perPage));
return response()->json([
'data' => $items,
]);
}
}

View File

@@ -32,6 +32,7 @@ use Carbon\Carbon;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService;
use App\Services\Activity\UserActivityService;
use App\Services\ArtworkAttributionService;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
@@ -39,6 +40,8 @@ use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\DraftQuotaService;
use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use App\Models\Group;
use App\Services\GroupArtworkReviewService;
use Illuminate\Support\Str;
final class UploadController extends Controller
@@ -555,7 +558,7 @@ final class UploadController extends Controller
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService)
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution)
{
$user = $request->user();
@@ -572,6 +575,14 @@ final class UploadController extends Controller
'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'group' => ['nullable', 'string', 'max:90'],
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
'contributor_user_ids.*' => ['integer', 'min:1'],
'contributor_credits' => ['nullable', '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'],
]);
$mode = $validated['mode'] ?? 'now';
@@ -623,6 +634,8 @@ final class UploadController extends Controller
}
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->artwork_timezone = $validated['timezone'] ?? null;
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id;
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
// Sync category if provided
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
@@ -643,6 +656,9 @@ final class UploadController extends Controller
$artwork->tags()->sync($tagIds);
}
$artwork->save();
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
$artwork->visibility = $visibility;
@@ -735,4 +751,56 @@ final class UploadController extends Controller
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews)
{
$user = $request->user();
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'group' => ['required', 'string', 'max:90'],
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
'contributor_user_ids.*' => ['integer', 'min:1'],
'contributor_credits' => ['nullable', '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'],
]);
if (! ctype_digit($id)) {
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$artwork = Artwork::query()->find((int) $id);
if (! $artwork) {
return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $artwork->user_id !== (int) $user->id && (int) ($artwork->uploaded_by_user_id ?? 0) !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
$group = Group::query()->with('members')->where('slug', (string) $validated['group'])->first();
if (! $group) {
return response()->json(['message' => 'Group not found.'], Response::HTTP_NOT_FOUND);
}
$artwork = $reviews->submit($group, $artwork, $user, $validated);
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'submitted_for_review',
'group_review_status' => (string) $artwork->group_review_status,
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\GroupAsset;
use App\Services\GroupAssetService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GroupAssetController extends Controller
{
public function __construct(private readonly GroupAssetService $assets)
{
}
public function download(Request $request, Group $group, GroupAsset $asset): StreamedResponse
{
$this->authorize('view', $group);
abort_unless((int) $asset->group_id === (int) $group->id, 404);
abort_unless($asset->canBeViewedBy($request->user()), 403);
return $this->assets->downloadResponse($asset);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\Groups\AttachArtworkToGroupChallengeRequest;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Services\GroupChallengeService;
use App\Services\GroupService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class GroupChallengeController extends Controller
{
public function __construct(
private readonly GroupService $groups,
private readonly GroupChallengeService $challenges,
) {
}
public function show(Request $request, Group $group, GroupChallenge $challenge): Response
{
$this->authorize('view', $group);
abort_unless((int) $challenge->group_id === (int) $group->id, 404);
abort_unless($challenge->canBeViewedBy($request->user()), 403);
$groupPayload = $this->groups->mapGroupDetail($group, $request->user());
$challengePayload = $this->challenges->detailPayload($challenge, $request->user());
$canonical = route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]);
$description = Str::limit(trim(strip_tags((string) ($challengePayload['summary'] ?? $challengePayload['description'] ?? $groupPayload['headline'] ?? 'Group challenge on Skinbase.'))), 160, '…');
$seo = app(SeoFactory::class)->collectionPage(
sprintf('%s — %s — Skinbase', $challenge->title, $group->name),
$description,
$canonical,
$challengePayload['cover_url'] ?? null,
)->toArray();
$seo['og_type'] = 'article';
$seo['json_ld'] = [[
'@context' => 'https://schema.org',
'@type' => 'CreativeWork',
'name' => (string) $challenge->title,
'description' => $description,
'url' => $canonical,
'image' => $challengePayload['cover_url'] ?? null,
'dateCreated' => $challenge->created_at?->toAtomString(),
'publisher' => ['@type' => 'Organization', 'name' => (string) $group->name],
]];
return Inertia::render('Group/GroupChallengeShow', [
'group' => $groupPayload,
'challenge' => $challengePayload,
'seo' => $seo,
])->rootView('collections');
}
public function attachArtwork(AttachArtworkToGroupChallengeRequest $request, Group $group, GroupChallenge $challenge): RedirectResponse
{
$this->authorize('view', $group);
abort_unless((int) $challenge->group_id === (int) $group->id, 404);
abort_unless($challenge->canBeViewedBy($request->user()), 403);
$artwork = Artwork::query()->findOrFail((int) $request->validated('artwork_id'));
$this->challenges->attachArtwork($challenge, $artwork, $request->user());
return back()->with('success', 'Artwork attached to challenge.');
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\Leaderboard;
use App\Services\GroupDiscoveryService;
use App\Services\GroupMembershipService;
use App\Services\GroupService;
use App\Services\LeaderboardService;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Http\Request;
class GroupController extends Controller
{
public function __construct(
private readonly GroupService $groups,
private readonly GroupMembershipService $memberships,
private readonly GroupDiscoveryService $discovery,
private readonly LeaderboardService $leaderboards,
) {
}
public function index(Request $request): Response
{
$viewer = $request->user();
$surface = (string) $request->query('surface', 'featured');
$groups = $this->discovery->publicListing(
$viewer,
$surface,
(int) $request->query('page', 1),
24,
)
->through(fn (Group $group): array => $this->groups->mapGroupCard($group, $viewer));
return Inertia::render('Group/GroupIndex', [
'title' => 'Groups',
'description' => 'Collective publishing identities for collaborative artwork, collections, and shared presence on Skinbase Nova.',
'surfaces' => $this->discovery->availableSurfaces(),
'currentSurface' => $surface,
'spotlightGroup' => $this->discovery->spotlightCard($viewer, $surface),
'highlightSections' => [
[
'key' => 'featured',
'title' => 'Featured groups',
'description' => 'Established collectives publishing together across artworks, releases, and community initiatives.',
'items' => $this->discovery->surfaceCards($viewer, 'featured', 4),
],
[
'key' => 'recruiting',
'title' => 'Open for collaborators',
'description' => 'Groups currently recruiting artists, curators, moderators, and production contributors.',
'items' => $this->discovery->surfaceCards($viewer, 'recruiting', 4),
],
[
'key' => 'new_rising',
'title' => 'New and rising',
'description' => 'Emerging groups building momentum through fresh releases, shared projects, and fast growth.',
'items' => $this->discovery->surfaceCards($viewer, 'new_rising', 4),
],
],
'leaderboard' => $this->leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, Leaderboard::PERIOD_MONTHLY, 5),
'groups' => [
'data' => $groups->items(),
'meta' => [
'current_page' => $groups->currentPage(),
'last_page' => $groups->lastPage(),
'per_page' => $groups->perPage(),
'total' => $groups->total(),
],
],
])->rootView('collections');
}
public function show(Request $request, Group $group, string $section = 'overview'): Response
{
$this->authorize('view', $group);
$viewer = $request->user();
$group->loadMissing('owner.profile');
$members = collect($this->memberships->mapMembers($group, $viewer))
->where('status', Group::STATUS_ACTIVE)
->values()
->all();
$groupPayload = $this->groups->mapGroupDetail($group, $viewer);
return Inertia::render('Group/GroupShow', [
'group' => $groupPayload,
'section' => in_array($section, ['overview', 'artworks', 'collections', 'members', 'about', 'posts', 'projects', 'releases', 'challenges', 'events', 'activity'], true) ? $section : 'overview',
'featuredArtworks' => $this->groups->featuredArtworkCards($group),
'artworks' => $this->groups->publicArtworkCards($group),
'featuredCollections' => $this->groups->featuredCollectionCards($group, $viewer),
'collections' => $this->groups->publicCollectionCards($group, $viewer),
'members' => $members,
'leadership' => $this->groups->mapLeadershipPreview($members, $groupPayload['owner'] ?? []),
'posts' => $this->groups->publicPostListing($group),
'projects' => $this->groups->publicProjectListing($group, $viewer),
'releases' => $this->groups->publicReleaseListing($group, $viewer),
'challenges' => $this->groups->publicChallengeListing($group, $viewer),
'events' => $this->groups->publicEventListing($group, $viewer),
'assets' => $this->groups->publicAssetListing($group),
'activity' => $this->groups->publicActivityFeed($group),
'topContributors' => $groupPayload['top_contributors'] ?? [],
'trustSignals' => $groupPayload['trust_signals'] ?? [],
'badgeShowcase' => $groupPayload['badge_showcase'] ?? [],
'recruitment' => $this->groups->recruitmentPayload($group),
'reportEndpoint' => $viewer ? route('api.reports.store') : null,
])->rootView('collections');
}
public function posts(Request $request, Group $group): Response
{
return $this->show($request, $group, 'posts');
}
public function projects(Request $request, Group $group): Response
{
return $this->show($request, $group, 'projects');
}
public function releases(Request $request, Group $group): Response
{
return $this->show($request, $group, 'releases');
}
public function challenges(Request $request, Group $group): Response
{
return $this->show($request, $group, 'challenges');
}
public function events(Request $request, Group $group): Response
{
return $this->show($request, $group, 'events');
}
public function activity(Request $request, Group $group): Response
{
return $this->show($request, $group, 'activity');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Group;
use App\Services\GroupFollowService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GroupEngagementController extends Controller
{
public function __construct(
private readonly GroupFollowService $follows,
) {
}
public function follow(Request $request, Group $group): JsonResponse
{
$this->authorize('view', $group);
$this->follows->follow($group, $request->user());
return response()->json([
'ok' => true,
'following' => true,
'followers_count' => (int) $group->fresh()->followers_count,
]);
}
public function unfollow(Request $request, Group $group): JsonResponse
{
$this->authorize('view', $group);
$this->follows->unfollow($group, $request->user());
return response()->json([
'ok' => true,
'following' => false,
'followers_count' => (int) $group->fresh()->followers_count,
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\GroupEvent;
use App\Services\GroupEventService;
use App\Services\GroupService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class GroupEventController extends Controller
{
public function __construct(
private readonly GroupService $groups,
private readonly GroupEventService $events,
) {
}
public function show(Request $request, Group $group, GroupEvent $event): Response
{
$this->authorize('view', $group);
abort_unless((int) $event->group_id === (int) $group->id, 404);
abort_unless($event->canBeViewedBy($request->user()), 403);
$groupPayload = $this->groups->mapGroupDetail($group, $request->user());
$eventPayload = $this->events->detailPayload($event);
$canonical = route('groups.events.show', ['group' => $group, 'event' => $event]);
$description = Str::limit(trim(strip_tags((string) ($eventPayload['summary'] ?? $eventPayload['description'] ?? $groupPayload['headline'] ?? 'Group event on Skinbase.'))), 160, '…');
$seo = app(SeoFactory::class)->collectionPage(
sprintf('%s — %s — Skinbase', $event->title, $group->name),
$description,
$canonical,
$eventPayload['cover_url'] ?? null,
)->toArray();
$seo['og_type'] = 'article';
$seo['json_ld'] = [[
'@context' => 'https://schema.org',
'@type' => 'Event',
'name' => (string) $event->title,
'description' => $description,
'url' => $canonical,
'image' => $eventPayload['cover_url'] ?? null,
'startDate' => $event->start_at?->toAtomString(),
'endDate' => $event->end_at?->toAtomString(),
'eventAttendanceMode' => 'https://schema.org/OnlineEventAttendanceMode',
'eventStatus' => 'https://schema.org/EventScheduled',
'organizer' => ['@type' => 'Organization', 'name' => (string) $group->name],
]];
return Inertia::render('Group/GroupEventShow', [
'group' => $groupPayload,
'event' => $eventPayload,
'seo' => $seo,
])->rootView('collections');
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\Groups\StoreGroupJoinRequestRequest;
use App\Models\Group;
use App\Models\GroupJoinRequest;
use App\Services\GroupJoinRequestService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class GroupJoinRequestController extends Controller
{
public function __construct(
private readonly GroupJoinRequestService $joinRequests,
) {
}
public function store(StoreGroupJoinRequestRequest $request, Group $group): RedirectResponse|JsonResponse
{
$this->authorize('requestJoin', $group);
$joinRequest = $this->joinRequests->submit($group, $request->user(), $request->validated());
if ($request->expectsJson()) {
return response()->json([
'ok' => true,
'join_request' => $this->joinRequests->mapRequest($joinRequest, $group, $request->user()),
]);
}
return back()->with('success', 'Your join request has been sent.');
}
public function destroy(Request $request, Group $group, GroupJoinRequest $joinRequest): RedirectResponse|JsonResponse
{
abort_unless((int) $joinRequest->group_id === (int) $group->id, 404);
$updated = $this->joinRequests->withdraw($joinRequest, $request->user());
if ($request->expectsJson()) {
return response()->json([
'ok' => true,
'join_request' => $this->joinRequests->mapRequest($updated, $group, $request->user()),
]);
}
return back()->with('success', 'Your join request has been withdrawn.');
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\Groups\StoreGroupMemberRequest;
use App\Http\Requests\Groups\UpdateGroupMemberPermissionsRequest;
use App\Http\Requests\Groups\UpdateGroupMemberRequest;
use App\Models\Group;
use App\Models\GroupInvitation;
use App\Models\GroupMember;
use App\Models\User;
use App\Services\GroupMembershipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class GroupMemberController extends Controller
{
public function __construct(
private readonly GroupMembershipService $memberships,
) {
}
public function store(StoreGroupMemberRequest $request, Group $group): JsonResponse
{
$this->authorize('manageMembers', $group);
$invitee = User::query()
->whereRaw('LOWER(username) = ?', [strtolower((string) $request->validated('username'))])
->firstOrFail();
$invitation = $this->memberships->inviteMember(
$group,
$request->user(),
$invitee,
(string) $request->validated('role'),
$request->validated('note'),
$request->integer('expires_in_days') ?: null,
);
return response()->json([
'ok' => true,
'member' => $invitation,
'invitation' => $invitation,
'members' => $this->memberships->mapMembers($group, $request->user()),
'invitations' => $this->memberships->mapInvitations($group, $request->user()),
]);
}
public function update(UpdateGroupMemberRequest $request, Group $group, GroupMember $member): JsonResponse
{
$this->authorize('manageMembers', $group);
abort_unless((int) $member->group_id === (int) $group->id, 404);
$updated = $this->memberships->updateMemberRole($member, $request->user(), (string) $request->validated('role'));
return response()->json([
'ok' => true,
'member' => $updated,
'members' => $this->memberships->mapMembers($group, $request->user()),
]);
}
public function transfer(Request $request, Group $group, GroupMember $member): JsonResponse
{
$this->authorize('update', $group);
abort_unless((int) $member->group_id === (int) $group->id, 404);
$group = $this->memberships->transferOwnership($group, $member, $request->user());
return response()->json([
'ok' => true,
'group_id' => (int) $group->id,
'members' => $this->memberships->mapMembers($group, $request->user()),
]);
}
public function updatePermissions(UpdateGroupMemberPermissionsRequest $request, Group $group, GroupMember $member): JsonResponse
{
$this->authorize('manageMemberPermissions', $group);
abort_unless((int) $member->group_id === (int) $group->id, 404);
$updated = $this->memberships->updatePermissionOverrides(
$member,
$request->user(),
$request->validated('permission_overrides') ?? [],
);
$members = $this->memberships->mapMembers($group, $request->user());
return response()->json([
'ok' => true,
'member' => collect($members)->firstWhere('id', (int) $updated->id),
'members' => $members,
]);
}
public function destroy(Request $request, Group $group, GroupMember $member): JsonResponse
{
$this->authorize('manageMembers', $group);
abort_unless((int) $member->group_id === (int) $group->id, 404);
$this->memberships->revokeMember($member, $request->user());
return response()->json([
'ok' => true,
'members' => $this->memberships->mapMembers($group, $request->user()),
]);
}
public function acceptInvitation(Request $request, GroupInvitation $invitation): RedirectResponse
{
$member = $this->memberships->acceptInvitation($invitation, $request->user());
return redirect()->route('studio.groups.members', ['group' => $member->group]);
}
public function declineInvitation(Request $request, GroupInvitation $invitation): RedirectResponse
{
$this->memberships->declineInvitation($invitation, $request->user());
return redirect()->route('studio.groups.index');
}
public function destroyInvitation(Request $request, Group $group, GroupInvitation $invitation): JsonResponse
{
$this->authorize('manageMembers', $group);
abort_unless((int) $invitation->group_id === (int) $group->id, 404);
$this->memberships->revokeInvitation($invitation, $request->user());
return response()->json([
'ok' => true,
'invitations' => $this->memberships->mapInvitations($group, $request->user()),
]);
}
public function accept(Request $request, GroupMember $member): RedirectResponse
{
$this->memberships->acceptLegacyInvite($member, $request->user());
return redirect()->route('studio.groups.members', ['group' => $member->group]);
}
public function decline(Request $request, GroupMember $member): RedirectResponse
{
$this->memberships->declineLegacyInvite($member, $request->user());
return redirect()->route('studio.groups.index');
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\GroupPost;
use App\Services\GroupService;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Http\Request;
class GroupPostController extends Controller
{
public function __construct(
private readonly GroupService $groups,
) {
}
public function show(Request $request, Group $group, GroupPost $post): Response
{
$this->authorize('view', $group);
abort_unless((int) $post->group_id === (int) $group->id && $post->status === GroupPost::STATUS_PUBLISHED, 404);
$canonical = route('groups.posts.show', ['group' => $group, 'post' => $post]);
$description = $post->excerpt ?: Str::limit(trim(strip_tags((string) $post->content)), 160, '...');
$coverImage = $post->cover_path ?: ($group->bannerUrl() ?: $group->avatarUrl());
return Inertia::render('Group/GroupPostShow', [
'group' => $this->groups->mapGroupDetail($group, $request->user()),
'post' => [
'id' => (int) $post->id,
'type' => (string) $post->type,
'title' => (string) $post->title,
'excerpt' => $post->excerpt,
'content' => $post->content,
'is_pinned' => (bool) $post->is_pinned,
'published_at' => $post->published_at?->toISOString(),
'author' => $post->author ? [
'id' => (int) $post->author->id,
'name' => $post->author->name,
'username' => $post->author->username,
] : null,
],
'recentPosts' => $this->groups->recentPostCards($group, 4),
'reportEndpoint' => $request->user() ? route('api.reports.store') : null,
'seo' => [
'title' => $post->title . ' - ' . $group->name . ' - Skinbase',
'description' => $description,
'canonical' => $canonical,
'og_title' => $post->title . ' - ' . $group->name,
'og_description' => $description,
'og_url' => $canonical,
'og_type' => 'article',
'og_image' => $coverImage,
'twitter_card' => $coverImage ? 'summary_large_image' : 'summary',
'twitter_image' => $coverImage,
'json_ld' => [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $post->title,
'description' => $description,
'datePublished' => $post->published_at?->toIso8601String(),
'dateModified' => $post->updated_at?->toIso8601String(),
'mainEntityOfPage' => $canonical,
'author' => $post->author ? [
'@type' => 'Person',
'name' => $post->author->name ?: $post->author->username,
] : null,
'publisher' => [
'@type' => 'Organization',
'name' => $group->name,
'url' => $group->publicUrl(),
'logo' => $group->avatarUrl() ? [
'@type' => 'ImageObject',
'url' => $group->avatarUrl(),
] : null,
],
'image' => $coverImage ? [$coverImage] : null,
],
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\GroupProject;
use App\Services\GroupService;
use App\Services\GroupProjectService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class GroupProjectController extends Controller
{
public function __construct(
private readonly GroupService $groups,
private readonly GroupProjectService $projects,
) {
}
public function show(Request $request, Group $group, GroupProject $project): Response
{
$this->authorize('view', $group);
abort_unless((int) $project->group_id === (int) $group->id, 404);
abort_unless($project->canBeViewedBy($request->user()), 403);
$groupPayload = $this->groups->mapGroupDetail($group, $request->user());
$projectPayload = $this->projects->detailPayload($project, $request->user());
$canonical = route('groups.projects.show', ['group' => $group, 'project' => $project]);
$description = Str::limit(trim(strip_tags((string) ($projectPayload['summary'] ?? $projectPayload['description'] ?? $groupPayload['headline'] ?? 'Group project on Skinbase.'))), 160, '…');
$seo = app(SeoFactory::class)->collectionPage(
sprintf('%s — %s — Skinbase', $project->title, $group->name),
$description,
$canonical,
$projectPayload['cover_url'] ?? null,
)->toArray();
$seo['og_type'] = 'article';
$seo['json_ld'] = [[
'@context' => 'https://schema.org',
'@type' => 'CreativeWork',
'name' => (string) $project->title,
'description' => $description,
'url' => $canonical,
'image' => $projectPayload['cover_url'] ?? null,
'dateCreated' => $project->created_at?->toAtomString(),
'publisher' => ['@type' => 'Organization', 'name' => (string) $group->name],
]];
return Inertia::render('Group/GroupProjectShow', [
'group' => $groupPayload,
'project' => $projectPayload,
'seo' => $seo,
])->rootView('collections');
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\GroupRelease;
use App\Services\GroupReleaseService;
use App\Services\GroupService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class GroupReleaseController extends Controller
{
public function __construct(
private readonly GroupService $groups,
private readonly GroupReleaseService $releases,
) {
}
public function show(Request $request, Group $group, GroupRelease $release): Response
{
$this->authorize('view', $group);
abort_unless((int) $release->group_id === (int) $group->id, 404);
abort_unless($release->canBeViewedBy($request->user()), 403);
$groupPayload = $this->groups->mapGroupDetail($group, $request->user());
$releasePayload = $this->releases->detailPayload($release, $request->user());
$canonical = route('groups.releases.show', ['group' => $group, 'release' => $release]);
$description = Str::limit(trim(strip_tags((string) ($releasePayload['summary'] ?? $releasePayload['description'] ?? $groupPayload['headline'] ?? 'Group release on Skinbase.'))), 160, '…');
$seo = app(SeoFactory::class)->collectionPage(
sprintf('%s — %s — Skinbase', $release->title, $group->name),
$description,
$canonical,
$releasePayload['cover_url'] ?? null,
)->toArray();
$seo['og_type'] = 'article';
$seo['json_ld'] = [[
'@context' => 'https://schema.org',
'@type' => 'CreativeWork',
'name' => (string) $release->title,
'description' => $description,
'url' => $canonical,
'image' => $releasePayload['cover_url'] ?? null,
'datePublished' => $release->published_at?->toAtomString(),
'dateCreated' => $release->created_at?->toAtomString(),
'publisher' => ['@type' => 'Organization', 'name' => (string) $group->name],
]];
return Inertia::render('Group/GroupReleaseShow', [
'group' => $groupPayload,
'release' => $releasePayload,
'seo' => $seo,
])->rootView('collections');
}
}

View File

@@ -3,8 +3,11 @@
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\News\NewsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
@@ -12,32 +15,44 @@ use cPad\Plugins\News\Models\NewsView;
class NewsController extends Controller
{
public function __construct(private readonly NewsService $news)
{
}
// -----------------------------------------------------------------------
// Homepage — /news
// -----------------------------------------------------------------------
public function index(Request $request)
public function index(Request $request): View
{
$perPage = config('news.articles_per_page', 12);
$featured = NewsArticle::with('author', 'category')
->published()
->featured()
->orderByDesc('published_at')
->editorialOrder()
->first();
$query = NewsArticle::with('author', 'category')
$highlightQuery = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at');
->editorialOrder();
if ($featured) {
$query->where('id', '!=', $featured->id);
$highlightQuery->where('id', '!=', $featured->id);
}
$articles = $query->paginate($perPage);
$highlights = $highlightQuery->limit(3)->get();
$excludedIds = collect([$featured?->id])->merge($highlights->pluck('id'))->filter()->all();
$articles = NewsArticle::with('author', 'category')
->published()
->when($excludedIds !== [], fn ($query) => $query->whereNotIn('id', $excludedIds))
->editorialOrder()
->paginate($perPage);
return view('news.index', [
'featured' => $featured,
'articles' => $articles,
'featured' => $featured,
'highlights' => $highlights,
'articles' => $articles,
] + $this->sidebarData());
}
@@ -45,7 +60,7 @@ class NewsController extends Controller
// Category page — /news/category/{slug}
// -----------------------------------------------------------------------
public function category(Request $request, string $slug)
public function category(Request $request, string $slug): View
{
$category = NewsCategory::where('slug', $slug)->where('is_active', true)->firstOrFail();
$perPage = config('news.articles_per_page', 12);
@@ -53,12 +68,12 @@ class NewsController extends Controller
$articles = NewsArticle::with('author', 'category')
->published()
->byCategory($category->id)
->orderByDesc('published_at')
->editorialOrder()
->paginate($perPage);
return view('news.category', [
'category' => $category,
'articles' => $articles,
'category' => $category,
'articles' => $articles,
] + $this->sidebarData());
}
@@ -66,7 +81,7 @@ class NewsController extends Controller
// Tag page — /news/tag/{slug}
// -----------------------------------------------------------------------
public function tag(Request $request, string $slug)
public function tag(Request $request, string $slug): View
{
$tag = NewsTag::where('slug', $slug)->firstOrFail();
$perPage = config('news.articles_per_page', 12);
@@ -74,12 +89,46 @@ class NewsController extends Controller
$articles = NewsArticle::with('author', 'category')
->published()
->whereHas('tags', fn ($q) => $q->where('news_tags.slug', $slug))
->orderByDesc('published_at')
->editorialOrder()
->paginate($perPage);
return view('news.tag', [
'tag' => $tag,
'articles' => $articles,
'tag' => $tag,
'articles' => $articles,
] + $this->sidebarData());
}
public function archive(Request $request, int $year, int $month): View
{
abort_unless($month >= 1 && $month <= 12, 404);
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->whereYear('published_at', $year)
->whereMonth('published_at', $month)
->editorialOrder()
->paginate($perPage);
return view('news.archive', [
'archiveDate' => now()->setDate($year, $month, 1),
'articles' => $articles,
] + $this->sidebarData());
}
public function author(Request $request, string $username): View
{
$author = User::query()->with('profile')->where('username', $username)->firstOrFail();
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->where('author_id', $author->id)
->editorialOrder()
->paginate($perPage);
return view('news.author', [
'author' => $author,
'articles' => $articles,
] + $this->sidebarData());
}
@@ -87,9 +136,9 @@ class NewsController extends Controller
// Article page — /news/{slug}
// -----------------------------------------------------------------------
public function show(Request $request, string $slug)
public function show(Request $request, string $slug): View
{
$article = NewsArticle::with('author', 'category', 'tags')
$article = NewsArticle::with('author.profile', 'category', 'tags', 'relatedEntities')
->published()
->where('slug', $slug)
->firstOrFail();
@@ -98,17 +147,18 @@ class NewsController extends Controller
$this->trackView($request, $article);
// Related articles (same category, excluding current)
$related = NewsArticle::with('author')
$related = NewsArticle::with('author', 'category')
->published()
->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->orderByDesc('published_at')
->editorialOrder()
->limit(config('news.related_limit', 4))
->get();
return view('news.show', [
'article' => $article,
'related' => $related,
'relatedEntities' => $this->news->resolveRelatedEntities($article, $request->user()),
] + $this->sidebarData());
}
@@ -140,13 +190,6 @@ class NewsController extends Controller
private function sidebarData(): array
{
return [
'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(),
'trending' => NewsArticle::published()
->orderByDesc('views')
->limit(config('news.trending_limit', 5))
->get(['id', 'title', 'slug', 'views', 'published_at']),
'tags' => NewsTag::has('articles')->orderBy('name')->get(),
];
return $this->news->sidebarData();
}
}

View File

@@ -19,6 +19,7 @@ use App\Http\Requests\Collections\UpdateCollectionPresentationRequest;
use App\Http\Requests\Collections\UpdateCollectionSeriesRequest;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\Group;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionCampaignService;
use App\Services\CollectionCommentService;
@@ -56,6 +57,12 @@ class CollectionManageController extends Controller
$initialMode = $request->query('mode') === Collection::MODE_SMART
? Collection::MODE_SMART
: Collection::MODE_MANUAL;
$group = null;
if ($request->filled('group')) {
$group = Group::query()->with(['owner.profile', 'members'])->where('slug', (string) $request->query('group'))->first();
abort_if($group && ! $group->canManageCollections($request->user()), 403);
}
return Inertia::render('Collection/CollectionManage', [
'mode' => 'create',
@@ -67,7 +74,7 @@ class CollectionManageController extends Controller
'smartRuleOptions' => $this->collections->getSmartRuleOptions($request->user()),
'initialMode' => $initialMode,
'featuredLimit' => (int) config('collections.featured_limit', 3),
'owner' => $this->ownerPayload($request),
'owner' => $this->ownerPayload($request, $group),
'members' => [],
'submissions' => [],
'comments' => [],
@@ -75,7 +82,7 @@ class CollectionManageController extends Controller
'canonicalTarget' => null,
'inviteExpiryDays' => (int) config('collections.invites.expires_after_days', 7),
'endpoints' => [
'store' => route('settings.collections.store'),
'store' => route('settings.collections.store', $group ? ['group' => $group->slug] : []),
'smartPreview' => route('settings.collections.smart.preview'),
'profileCollections' => route('profile.tab', [
'username' => strtolower((string) $request->user()->username),
@@ -337,7 +344,11 @@ class CollectionManageController extends Controller
{
$artwork = $this->resolveArtworkFromRequest($request, 4);
abort_unless((int) $artwork->user_id === (int) $request->user()->id, 404);
if ((int) ($artwork->group_id ?? 0) > 0) {
abort_unless($artwork->group?->canManageCollections($request->user()) ?? false, 404);
} else {
abort_unless((int) $artwork->user_id === (int) $request->user()->id, 404);
}
return response()->json([
'data' => $this->collections->getCollectionOptionsForArtwork($request->user(), $artwork),
@@ -464,6 +475,21 @@ class CollectionManageController extends Controller
{
$user = $request->user();
if ($request->filled('group')) {
$group = Group::query()->with('owner.profile')->where('slug', (string) $request->query('group'))->first();
if ($group && $group->canManageCollections($user)) {
return [
'id' => $group->id,
'username' => null,
'name' => $group->name,
'avatar_url' => $group->avatarUrl(),
'group_slug' => $group->slug,
'profile_url' => $group->publicUrl(),
];
}
}
return [
'id' => $user->id,
'username' => $user->username,

View File

@@ -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.');
}
}

View File

@@ -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.');
}
}

View File

@@ -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.');
}
}

View File

@@ -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.');
}
}

View File

@@ -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.');
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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.');
}
}

View File

@@ -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.');
}
}

View File

@@ -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.');
}
}

View File

@@ -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(),
],
]);
}
}

View File

@@ -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.');
}
}

View File

@@ -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]);
}
}

View File

@@ -11,6 +11,7 @@ use App\Models\ContentType;
use App\Models\ArtworkVersion;
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;
@@ -118,7 +119,7 @@ final class StudioArtworksApiController extends Controller
* PUT /api/studio/artworks/{id}
* Update artwork details (title, description, visibility).
*/
public function update(Request $request, int $id): JsonResponse
public function update(Request $request, int $id, ArtworkAttributionService $attribution): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
@@ -138,8 +139,32 @@ final class StudioArtworksApiController extends Controller
'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',
]);
$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);
$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)
@@ -165,7 +190,7 @@ final class StudioArtworksApiController extends Controller
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
$contentTypeId = $validated['content_type_id'] ?? null;
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone']);
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['visibility'] = $visibility;
$validated['artwork_timezone'] = $timezone;
@@ -215,6 +240,10 @@ final class StudioArtworksApiController extends Controller
}
}
if ($hasAttributionUpdates) {
$artwork = $attribution->apply($artwork->fresh(['group.members', 'contributors', 'primaryAuthor.profile']), $request->user(), $attributionPayload);
}
// Reindex in Meilisearch
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
@@ -227,7 +256,7 @@ final class StudioArtworksApiController extends Controller
}
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags']);
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first();
return response()->json([
@@ -243,6 +272,14 @@ final class StudioArtworksApiController extends Controller
'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(),

View File

@@ -5,7 +5,10 @@ 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\GroupMembershipService;
use App\Services\GroupService;
use App\Services\Studio\CreatorStudioAnalyticsService;
use App\Services\Studio\CreatorStudioAssetService;
use App\Services\Studio\CreatorStudioCalendarService;
@@ -417,11 +420,24 @@ final class StudioController extends Controller
*/
public function edit(Request $request, int $id): Response
{
$artwork = $request->user()->artworks()
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist'])
$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' => [
@@ -443,6 +459,14 @@ final class StudioController extends Controller
'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,
@@ -459,6 +483,8 @@ final class StudioController extends Controller
'requires_reapproval' => (bool) $artwork->requires_reapproval,
],
'contentTypes' => $this->getCategories(),
'groupOptions' => $availableGroups,
'contributorOptionsByGroup' => $contributorOptionsByGroup,
]);
}

View File

@@ -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;
}
}

View File

@@ -15,6 +15,12 @@ use App\Mail\EmailChangedSecurityAlertMail;
use App\Mail\EmailChangeVerificationCodeMail;
use App\Models\Artwork;
use App\Models\Country;
use App\Models\Group;
use App\Models\GroupContributorStat;
use App\Models\GroupMember;
use App\Models\GroupProjectMember;
use App\Models\GroupRelease;
use App\Models\GroupReleaseContributor;
use App\Models\ProfileComment;
use App\Models\Story;
use App\Models\User;
@@ -1196,6 +1202,7 @@ class ProfileController extends Controller
->all();
$achievementSummary = $this->achievements->summary((int) $user->id);
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
$groupContributionHistory = $this->buildGroupContributionHistory($user);
$resolvedInitialTab = $this->normalizeProfileTab($initialTab);
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
$activeProfileUrl = $resolvedInitialTab !== null
@@ -1269,6 +1276,7 @@ class ProfileController extends Controller
'collections' => $profileCollectionsPayload,
'achievements' => $achievementSummary,
'leaderboardRank' => $leaderboardRank,
'groupContributionHistory' => $groupContributionHistory,
'countryName' => $countryName,
'isOwner' => $isOwner,
'auth' => $authData,
@@ -1315,6 +1323,98 @@ class ProfileController extends Controller
return redirect()->to($baseUrl, 301);
}
private function buildGroupContributionHistory(User $user): array
{
return GroupContributorStat::query()
->with(['group.owner.profile'])
->where('user_id', $user->id)
->whereHas('group', fn ($query) => $query
->whereIn('visibility', [Group::VISIBILITY_PUBLIC, Group::VISIBILITY_UNLISTED])
->where('status', '!=', Group::LIFECYCLE_SUSPENDED))
->orderByDesc('release_count')
->orderByDesc('credited_artworks_count')
->limit(8)
->get()
->map(function (GroupContributorStat $stat) use ($user): ?array {
$group = $stat->group;
if (! $group) {
return null;
}
$member = GroupMember::query()
->where('group_id', $group->id)
->where('user_id', $user->id)
->where('status', Group::STATUS_ACTIVE)
->first();
$roleLabels = collect()
->merge(GroupReleaseContributor::query()
->where('user_id', $user->id)
->whereHas('release', fn ($query) => $query->where('group_id', $group->id))
->pluck('role_label'))
->merge(GroupProjectMember::query()
->where('user_id', $user->id)
->whereHas('project', fn ($query) => $query->where('group_id', $group->id))
->pluck('role_label'))
->filter()
->map(fn ($label): string => trim((string) $label))
->filter()
->unique()
->values()
->all();
$recentReleaseTitles = GroupRelease::query()
->where('group_id', $group->id)
->where('visibility', GroupRelease::VISIBILITY_PUBLIC)
->where(function ($query) use ($user): void {
$query->where('lead_user_id', $user->id)
->orWhere('created_by_user_id', $user->id)
->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $user->id));
})
->orderByDesc('released_at')
->latest('updated_at')
->limit(3)
->pluck('title')
->map(fn ($title): string => (string) $title)
->values()
->all();
$joinedAt = $group->isOwnedBy($user)
? $group->created_at?->toISOString()
: ($member?->accepted_at?->toISOString() ?? $member?->created_at?->toISOString());
$meta = is_array($stat->reputation_meta_json) ? $stat->reputation_meta_json : [];
return [
'group' => [
'id' => (int) $group->id,
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => $group->headline,
'avatar_url' => $group->avatarUrl(),
'profile_url' => $group->publicUrl(),
],
'joined_at' => $joinedAt,
'role' => $group->isOwnedBy($user)
? Group::ROLE_OWNER
: Group::displayRole($member?->role ?? Group::ROLE_MEMBER),
'counts' => [
'credited_artworks' => (int) $stat->credited_artworks_count,
'releases' => (int) $stat->release_count,
'projects' => (int) $stat->project_count,
'review_actions' => (int) $stat->review_actions_count,
],
'trusted_indicator' => (bool) ($meta['trusted_indicator'] ?? false),
'summary' => $meta['summary'] ?? null,
'role_labels' => $roleLabels,
'recent_release_titles' => $recentReleaseTitles,
];
})
->filter()
->values()
->all();
}
private function resolveFavouriteTable(): ?string
{
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AccountHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.account');
$seo = app(SeoFactory::class)
->collectionPage(
'Account Settings Help — Skinbase',
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase Nova.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/AccountHelpPage', [
'title' => 'Account Settings Help',
'description' => 'Use this guide when account access already works and you need practical help with settings, identity details, email and password care, or ongoing account maintenance.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'help_auth' => route('help.auth'),
'help_profile' => route('help.profile'),
'studio_help' => route('help.studio'),
'upload_help' => route('help.upload'),
'help_troubleshooting' => route('help.troubleshooting'),
'profile_settings' => route('dashboard.profile'),
'open_studio' => route('studio.index'),
'login' => route('login'),
'register' => route('register'),
'password_request' => route('password.request'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -11,6 +11,7 @@ use App\Models\ArtworkComment;
use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService;
use App\Services\GroupService;
use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
@@ -22,6 +23,8 @@ use Illuminate\View\View;
final class ArtworkPageController extends Controller
{
public function __construct(private readonly GroupService $groups) {}
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
{
// ── Step 1: check existence including soft-deleted ─────────────────
@@ -84,7 +87,7 @@ final class ArtworkPageController extends Controller
}
// ── Step 2: full load with all relations ───────────────────────────
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat'])
$artwork = Artwork::with(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat'])
->where('id', $id)
->public()
->published()
@@ -108,9 +111,15 @@ final class ArtworkPageController extends Controller
$thumbSq = ThumbnailPresenter::present($artwork, 'sq');
$artworkData = (new ArtworkResource($artwork))->toArray($request);
$groupSummary = null;
if ($artwork->group) {
$artwork->group->loadMissing(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']);
$groupSummary = $this->groups->mapGroupCard($artwork->group, $request->user());
}
$canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]);
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
$authorName = $artwork->group?->name ?: $artwork->primaryAuthor?->name ?: $artwork->primaryAuthor?->username ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
$description = Str::limit(trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'))), 160, '…');
$meta = [
@@ -132,13 +141,17 @@ final class ArtworkPageController extends Controller
$tagIds = $artwork->tags->pluck('id')->filter()->values();
$related = Artwork::query()
->with(['user', 'categories.contentType'])
->with(['user', 'group', 'categories.contentType'])
->whereKeyNot($artwork->id)
->public()
->published()
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
$query->where('user_id', $artwork->user_id);
if ($artwork->group_id) {
$query->orWhere('group_id', $artwork->group_id);
}
if ($categoryIds->isNotEmpty()) {
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
$categoryQuery->whereIn('categories.id', $categoryIds->all());
@@ -166,7 +179,7 @@ final class ArtworkPageController extends Controller
return [
'id' => (int) $item->id,
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
@@ -237,6 +250,7 @@ final class ArtworkPageController extends Controller
'useUnifiedSeo' => true,
'relatedItems' => $related,
'comments' => $comments,
'groupSummary' => $groupSummary,
]);
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AuthHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.auth');
$seo = app(SeoFactory::class)
->collectionPage(
'Signup and Login Help — Skinbase',
'Learn how signup, login, password recovery, verification, and account access work on Skinbase Nova, with clear guidance for common access problems and practical next steps.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/AuthHelpPage', [
'title' => 'Signup & Login Help',
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase Nova.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'help_profile' => route('help.profile'),
'studio_help' => route('help.studio'),
'groups_help' => route('help.groups'),
'help_account' => route('help.account'),
'help_troubleshooting' => route('help.troubleshooting'),
'login' => route('login'),
'register' => route('register'),
'password_request' => route('password.request'),
'verification_notice' => route('verification.notice'),
'open_studio' => route('studio.index'),
'profile_settings' => route('dashboard.profile'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class CardsHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.cards');
$seo = app(SeoFactory::class)
->collectionPage(
'Cards Help — Skinbase',
'Learn what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/CardsHelpPage', [
'title' => 'Cards Help',
'description' => 'Understand Cards as a distinct creative format on Skinbase Nova, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'studio_help' => route('help.studio'),
'upload_help' => route('help.upload'),
'groups_help' => route('help.groups'),
'open_studio' => route('studio.index'),
'studio_cards' => route('studio.cards.index'),
'create_card' => route('studio.cards.create'),
'cards_index' => route('cards.index'),
'help_profile' => route('help.profile'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class GroupFaqPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.groups.faq');
$seo = app(SeoFactory::class)
->collectionPage(
'Groups FAQ — Skinbase',
'Fast answers to the most common Groups questions on Skinbase Nova, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Group/GroupFaqPage', [
'title' => 'Groups FAQ',
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase Nova.',
'seo' => $seo,
'links' => [
'groups_directory' => route('groups.index'),
'create_group' => route('studio.groups.create'),
'group_studio' => route('studio.groups.index'),
'full_documentation' => route('help.groups'),
'quickstart' => route('help.groups.quickstart'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
'help_home' => route('help'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class GroupHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.groups');
$seo = app(SeoFactory::class)
->collectionPage(
'Groups Guide, Help, and Best Practices — Skinbase',
'Learn how Groups work on Skinbase Nova, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Group/GroupHelpPage', [
'title' => 'Groups Help & Guide',
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase Nova.',
'seo' => $seo,
'links' => [
'groups_directory' => route('groups.index'),
'create_group' => route('studio.groups.create'),
'group_studio' => route('studio.groups.index'),
'quickstart' => route('help.groups.quickstart'),
'faq' => route('help.groups.faq'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
'help_home' => route('help'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class GroupQuickstartPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.groups.quickstart');
$seo = app(SeoFactory::class)
->collectionPage(
'Groups Quickstart — Skinbase',
'A fast, creator-friendly Groups quickstart for Skinbase Nova. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Group/GroupQuickstartPage', [
'title' => 'Groups Quickstart',
'description' => 'The fastest way to understand Groups, create one, invite members, and publish your first collaborative artwork with correct contributor credit.',
'seo' => $seo,
'links' => [
'groups_directory' => route('groups.index'),
'create_group' => route('studio.groups.create'),
'group_studio' => route('studio.groups.index'),
'full_documentation' => route('help.groups'),
'faq' => route('help.groups.faq'),
'help_home' => route('help'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class HelpCenterPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help');
$seo = app(SeoFactory::class)
->collectionPage(
'Help Center — Skinbase',
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova, including Groups, Studio, Upload, Cards, Profile, and account access.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
return Inertia::render('Help/HelpCenterPage', [
'title' => 'Help Center',
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova in one structured help hub.',
'seo' => $seo,
'links' => [
'studio_help' => route('help.studio'),
'upload_help' => route('help.upload'),
'groups_documentation' => route('help.groups'),
'groups_quickstart' => route('help.groups.quickstart'),
'groups_faq' => route('help.groups.faq'),
'groups_directory' => route('groups.index'),
'group_studio' => route('studio.groups.index'),
'create_group' => route('studio.groups.create'),
'open_studio' => route('studio.index'),
'studio_home' => route('studio.index'),
'studio_content' => route('studio.content'),
'studio_artworks' => route('studio.artworks'),
'studio_cards' => route('studio.cards.index'),
'studio_drafts' => route('studio.drafts'),
'cards_create' => route('studio.cards.create'),
'upload' => route('upload'),
'cards_index' => route('cards.index'),
'help_cards' => route('help.cards'),
'help_profile' => route('help.profile'),
'help_auth' => route('help.auth'),
'help_account' => route('help.account'),
'help_troubleshooting' => route('help.troubleshooting'),
'profile_settings' => route('dashboard.profile'),
'login' => route('login'),
'register' => route('register'),
'password_request' => route('password.request'),
'help_upload' => route('help', ['q' => 'upload']),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -19,17 +19,32 @@ class LeaderboardPageController extends Controller
$period = $leaderboards->normalizePeriod((string) $request->query('period', 'weekly'));
$type = match ((string) $request->query('type', 'creators')) {
'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK,
'groups', Leaderboard::TYPE_GROUP => Leaderboard::TYPE_GROUP,
'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY,
default => Leaderboard::TYPE_CREATOR,
};
$title = match ($type) {
Leaderboard::TYPE_GROUP => 'Top Groups Leaderboard — Skinbase',
Leaderboard::TYPE_STORY => 'Top Stories Leaderboard — Skinbase',
Leaderboard::TYPE_ARTWORK => 'Top Artworks Leaderboard — Skinbase',
default => 'Top Creators & Artworks Leaderboard — Skinbase',
};
$description = match ($type) {
Leaderboard::TYPE_GROUP => 'Track the leading groups across Skinbase by daily, weekly, monthly, and all-time performance.',
Leaderboard::TYPE_STORY => 'Track the leading stories across Skinbase by daily, weekly, monthly, and all-time performance.',
Leaderboard::TYPE_ARTWORK => 'Track the leading artworks across Skinbase by daily, weekly, monthly, and all-time performance.',
default => 'Track the leading creators, groups, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
};
return Inertia::render('Leaderboard/LeaderboardPage', [
'initialType' => $type,
'initialPeriod' => $period,
'initialData' => $leaderboards->getLeaderboard($type, $period),
'seo' => app(SeoFactory::class)->leaderboardPage(
'Top Creators & Artworks Leaderboard — Skinbase',
'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
$title,
$description,
route('leaderboard')
)->toArray(),
]);

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ProfileHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.profile');
$seo = app(SeoFactory::class)
->collectionPage(
'Profile Help — Skinbase',
'Learn how profiles work on Skinbase Nova, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/ProfileHelpPage', [
'title' => 'Profile Help',
'description' => 'Understand your Skinbase profile as your personal public identity, with practical guidance for setup, presentation, profile content, and creator-friendly best practices.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'groups_help' => route('help.groups'),
'studio_help' => route('help.studio'),
'upload_help' => route('help.upload'),
'cards_help' => route('help.cards'),
'profile_settings' => route('dashboard.profile'),
'open_studio' => route('studio.index'),
'help_auth' => route('help.auth'),
'login' => route('login'),
'register' => route('register'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -6,12 +6,17 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\ArtworkSearchService;
use App\Services\GroupDiscoveryService;
use Illuminate\Http\Request;
use Illuminate\View\View;
use cPad\Plugins\News\Models\NewsArticle;
final class SearchController extends Controller
{
public function __construct(private readonly ArtworkSearchService $search) {}
public function __construct(
private readonly ArtworkSearchService $search,
private readonly GroupDiscoveryService $groups,
) {}
public function index(Request $request): View
{
@@ -31,12 +36,33 @@ final class SearchController extends Controller
])
: $this->search->popular(24);
$groups = $q !== ''
? $this->groups->searchCards($q, $request->user(), 6)
: $this->groups->surfaceCards($request->user(), 'featured', 4);
$news = $q !== ''
? NewsArticle::query()
->with(['author:id,username,name', 'category:id,name,slug'])
->published()
->where(function ($builder) use ($q): void {
$builder->where('title', 'like', '%' . $q . '%')
->orWhere('excerpt', 'like', '%' . $q . '%')
->orWhere('content', 'like', '%' . $q . '%')
->orWhere('meta_title', 'like', '%' . $q . '%');
})
->editorialOrder()
->limit(4)
->get()
: collect();
return view('search.index', [
'q' => $q,
'sort' => $sort,
'groups' => $groups,
'artworks' => $artworks,
'news' => $news,
'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
'page_meta_description' => 'Search Skinbase for artworks, photography, wallpapers and skins.',
'page_meta_description' => 'Search Skinbase for artworks, creators, groups, photography, wallpapers and skins.',
'page_robots' => 'noindex,follow',
]);
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class StudioHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.studio');
$seo = app(SeoFactory::class)
->collectionPage(
'Studio Help — Skinbase',
'Learn how Studio works on Skinbase Nova, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/StudioHelpPage', [
'title' => 'Studio Help',
'description' => 'Understand Studio as the creative control center of Skinbase Nova, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'open_studio' => route('studio.index'),
'studio_content' => route('studio.content'),
'studio_artworks' => route('studio.artworks'),
'studio_drafts' => route('studio.drafts'),
'studio_scheduled' => route('studio.scheduled'),
'studio_collections' => route('studio.collections'),
'studio_settings' => route('studio.settings'),
'studio_cards' => route('studio.cards.index'),
'create_card' => route('studio.cards.create'),
'upload' => route('upload'),
'upload_help' => route('help.upload'),
'groups_help' => route('help.groups'),
'groups_quickstart' => route('help.groups.quickstart'),
'groups_faq' => route('help.groups.faq'),
'group_studio' => route('studio.groups.index'),
'help_cards' => route('help.cards'),
'help_profile' => route('help.profile'),
'help_auth' => route('help.auth'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class TroubleshootingHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.troubleshooting');
$seo = app(SeoFactory::class)
->collectionPage(
'Troubleshooting Help — Skinbase',
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase Nova.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/TroubleshootingHelpPage', [
'title' => 'Troubleshooting Help',
'description' => 'Use diagnosis-first help when something feels broken, blocked, or unclear and you need the fastest next step instead of a long module guide.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'help_auth' => route('help.auth'),
'help_account' => route('help.account'),
'help_profile' => route('help.profile'),
'studio_help' => route('help.studio'),
'upload_help' => route('help.upload'),
'groups_help' => route('help.groups'),
'groups_faq' => route('help.groups.faq'),
'profile_settings' => route('dashboard.profile'),
'open_studio' => route('studio.index'),
'login' => route('login'),
'register' => route('register'),
'password_request' => route('password.request'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class UploadHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.upload');
$seo = app(SeoFactory::class)
->collectionPage(
'Upload Help — Skinbase',
'Learn how uploading works on Skinbase Nova, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/UploadHelpPage', [
'title' => 'Upload Help',
'description' => 'Understand the full upload workflow on Skinbase Nova, from file submission and draft creation to metadata review, contributor credit, and final publish.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'upload' => route('upload'),
'studio_help' => route('help.studio'),
'open_studio' => route('studio.index'),
'studio_artworks' => route('studio.artworks'),
'studio_drafts' => route('studio.drafts'),
'groups_help' => route('help.groups'),
'groups_quickstart' => route('help.groups.quickstart'),
'groups_faq' => route('help.groups.faq'),
'group_studio' => route('studio.groups.index'),
'help_cards' => route('help.cards'),
'help_profile' => route('help.profile'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}