Compare commits
2 Commits
0b216b7ecd
...
08ad757bcb
| Author | SHA1 | Date | |
|---|---|---|---|
| 08ad757bcb | |||
| 148a3bbe43 |
626
app/Console/Commands/CheckArtworkUserReferencesCommand.php
Normal file
626
app/Console/Commands/CheckArtworkUserReferencesCommand.php
Normal file
@@ -0,0 +1,626 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class CheckArtworkUserReferencesCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:check-user-refs
|
||||
{--chunk=1000 : Number of artworks to process per chunk}
|
||||
{--show-missing=25 : Maximum number of missing references to print}
|
||||
{--artwork-id= : Only check/copy the user referenced by this specific artwork ID}
|
||||
{--copy-missing-from-legacy : Copy missing referenced users from the legacy users table into the new users table using the same id}
|
||||
{--create-placeholder : Create a placeholder tmpu{id} stub user when the legacy user cannot be found}
|
||||
{--dry-run-copy : Preview legacy user copies without writing them}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-users-table=users : Legacy users table name}
|
||||
{--json : Output the summary as JSON}';
|
||||
|
||||
protected $description = 'Check that every artworks.user_id points to an existing users.id row.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||
$showMissing = max(0, (int) $this->option('show-missing'));
|
||||
$copyMissingFromLegacy = (bool) $this->option('copy-missing-from-legacy');
|
||||
$createPlaceholder = (bool) $this->option('create-placeholder');
|
||||
$dryRunCopy = (bool) $this->option('dry-run-copy');
|
||||
$legacyConnection = (string) $this->option('legacy-connection');
|
||||
$legacyUsersTable = (string) $this->option('legacy-users-table');
|
||||
|
||||
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
|
||||
|
||||
$this->line(sprintf('Auditing artworks.user_id references in chunks of %d...', $chunkSize));
|
||||
|
||||
$audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId);
|
||||
$copySummary = null;
|
||||
|
||||
if ($copyMissingFromLegacy) {
|
||||
$this->newLine();
|
||||
$this->line(sprintf(
|
||||
'%s missing referenced users from legacy connection "%s" table "%s".',
|
||||
$dryRunCopy ? 'Previewing copy of' : 'Copying',
|
||||
$legacyConnection,
|
||||
$legacyUsersTable,
|
||||
));
|
||||
|
||||
try {
|
||||
$copySummary = $this->copyMissingUsersFromLegacy(
|
||||
array_keys($audit['missing_user_ids']),
|
||||
$legacyConnection,
|
||||
$legacyUsersTable,
|
||||
$dryRunCopy,
|
||||
$createPlaceholder,
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRunCopy && ($copySummary['copied'] ?? 0) > 0) {
|
||||
$audit = $this->auditArtworkUserReferences($chunkSize, $showMissing, $artworkId);
|
||||
}
|
||||
}
|
||||
|
||||
if ((bool) $this->option('json')) {
|
||||
$payload = [
|
||||
'summary' => $audit['summary'],
|
||||
'sample_missing' => $audit['sample_missing'],
|
||||
];
|
||||
|
||||
if ($copySummary !== null) {
|
||||
$payload['copy_summary'] = $copySummary;
|
||||
}
|
||||
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
$this->renderAuditSummary($audit['summary'], $audit['sample_missing']);
|
||||
|
||||
if ($copySummary !== null) {
|
||||
$this->renderCopySummary($copySummary);
|
||||
}
|
||||
|
||||
return ((int) ($audit['summary']['missing'] ?? 0)) === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: array{checked:int, valid:int, missing:int, null_user_ids:int},
|
||||
* sample_missing: array<int, array{artwork_id:int, user_id:string, title:string}>,
|
||||
* missing_user_ids: array<int, true>
|
||||
* }
|
||||
*/
|
||||
private function auditArtworkUserReferences(int $chunkSize, int $showMissing, ?int $artworkId = null): array
|
||||
{
|
||||
$checked = 0;
|
||||
$valid = 0;
|
||||
$missing = 0;
|
||||
$nullUserIds = 0;
|
||||
$sampleRows = [];
|
||||
$missingUserIds = [];
|
||||
|
||||
DB::table('artworks')
|
||||
->leftJoin('users', 'users.id', '=', 'artworks.user_id')
|
||||
->select([
|
||||
'artworks.id',
|
||||
'artworks.user_id',
|
||||
'artworks.title',
|
||||
DB::raw('users.id as matched_user_id'),
|
||||
])
|
||||
->when($artworkId !== null, fn ($q) => $q->where('artworks.id', $artworkId))
|
||||
->orderBy('artworks.id')
|
||||
->chunkById($chunkSize, function ($artworks) use (&$checked, &$valid, &$missing, &$nullUserIds, &$sampleRows, &$missingUserIds, $showMissing): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
$checked++;
|
||||
|
||||
if ($artwork->matched_user_id !== null) {
|
||||
$valid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing++;
|
||||
|
||||
if ($artwork->user_id === null) {
|
||||
$nullUserIds++;
|
||||
} else {
|
||||
$missingUserIds[(int) $artwork->user_id] = true;
|
||||
}
|
||||
|
||||
if (count($sampleRows) < $showMissing) {
|
||||
$sampleRows[] = [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'user_id' => $artwork->user_id === null ? '[null]' : (string) $artwork->user_id,
|
||||
'title' => (string) ($artwork->title ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->isVerboseOutput()) {
|
||||
$this->line(sprintf(
|
||||
' audited %d artworks so far; missing=%d, null_user_id=%d.',
|
||||
$checked,
|
||||
$missing,
|
||||
$nullUserIds,
|
||||
));
|
||||
}
|
||||
}, 'artworks.id', 'id');
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'checked' => $checked,
|
||||
'valid' => $valid,
|
||||
'missing' => $missing,
|
||||
'null_user_ids' => $nullUserIds,
|
||||
],
|
||||
'sample_missing' => $sampleRows,
|
||||
'missing_user_ids' => $missingUserIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string> $legacyIds
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function copyMissingUsersFromLegacy(array $legacyIds, string $legacyConnection, string $legacyUsersTable, bool $dryRun, bool $createPlaceholder = false): array
|
||||
{
|
||||
$result = [
|
||||
'requested_users' => count($legacyIds),
|
||||
'copied' => 0,
|
||||
'placeholders_created' => 0,
|
||||
'would_copy' => 0,
|
||||
'conflicts' => 0,
|
||||
'not_found_in_legacy' => 0,
|
||||
'errors' => 0,
|
||||
'dry_run' => $dryRun,
|
||||
'sample_copied_ids' => [],
|
||||
'sample_placeholder_ids' => [],
|
||||
'sample_conflict_ids' => [],
|
||||
'sample_not_found_ids' => [],
|
||||
'sample_error_messages' => [],
|
||||
];
|
||||
|
||||
if ($legacyIds === []) {
|
||||
if ($this->isVerboseOutput()) {
|
||||
$this->line('No missing non-null user ids were found to copy from the legacy users table.');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->ensureLegacyConnectionIsUsable($legacyConnection, $legacyUsersTable);
|
||||
|
||||
$normalizedLegacyIds = array_values(array_unique(array_map('intval', $legacyIds)));
|
||||
|
||||
foreach (array_chunk($normalizedLegacyIds, 200) as $chunkIndex => $chunk) {
|
||||
$legacyRows = DB::connection($legacyConnection)
|
||||
->table($legacyUsersTable)
|
||||
->whereIn('user_id', $chunk)
|
||||
->get()
|
||||
->keyBy(fn (object $row): int => (int) $row->user_id);
|
||||
|
||||
if ($this->isVerboseOutput()) {
|
||||
$this->line(sprintf(
|
||||
' processing legacy chunk %d with %d requested ids; found %d legacy rows.',
|
||||
$chunkIndex + 1,
|
||||
count($chunk),
|
||||
$legacyRows->count(),
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($chunk as $legacyId) {
|
||||
if (DB::table('users')->where('id', $legacyId)->exists()) {
|
||||
$result['conflicts']++;
|
||||
if (count($result['sample_conflict_ids']) < 10) {
|
||||
$result['sample_conflict_ids'][] = $legacyId;
|
||||
}
|
||||
|
||||
if ($this->isVerboseOutput()) {
|
||||
$this->warn(sprintf('[skip-conflict] user #%d already exists in the new users table.', $legacyId));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyUser = $legacyRows->get($legacyId);
|
||||
|
||||
if (! $legacyUser) {
|
||||
$result['not_found_in_legacy']++;
|
||||
if (count($result['sample_not_found_ids']) < 10) {
|
||||
$result['sample_not_found_ids'][] = $legacyId;
|
||||
}
|
||||
|
||||
if ($this->isVerboseOutput()) {
|
||||
$this->warn(sprintf(
|
||||
'[missing-legacy] user #%d was not found in %s.%s.',
|
||||
$legacyId,
|
||||
$legacyConnection,
|
||||
$legacyUsersTable,
|
||||
));
|
||||
}
|
||||
|
||||
if ($createPlaceholder) {
|
||||
if ($dryRun) {
|
||||
$result['would_copy']++;
|
||||
$this->line(sprintf('[dry-run] would create placeholder tmpu%d for user #%d', $legacyId, $legacyId));
|
||||
} else {
|
||||
try {
|
||||
$this->createPlaceholderUser($legacyId);
|
||||
$result['placeholders_created']++;
|
||||
if (count($result['sample_placeholder_ids']) < 10) {
|
||||
$result['sample_placeholder_ids'][] = $legacyId;
|
||||
}
|
||||
$this->info(sprintf('[placeholder] created tmpu%d for user #%d', $legacyId, $legacyId));
|
||||
} catch (\Throwable $exception) {
|
||||
$result['errors']++;
|
||||
$message = sprintf('#%d (placeholder): %s', $legacyId, $exception->getMessage());
|
||||
if (count($result['sample_error_messages']) < 10) {
|
||||
$result['sample_error_messages'][] = $message;
|
||||
}
|
||||
$this->error('[placeholder-error] ' . $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$result['would_copy']++;
|
||||
if (count($result['sample_copied_ids']) < 10) {
|
||||
$result['sample_copied_ids'][] = $legacyId;
|
||||
}
|
||||
|
||||
$this->line(sprintf('[dry-run] would import legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId)));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importLegacyUserBySameId($legacyUser, $legacyId);
|
||||
$result['copied']++;
|
||||
if (count($result['sample_copied_ids']) < 10) {
|
||||
$result['sample_copied_ids'][] = $legacyId;
|
||||
}
|
||||
|
||||
$this->info(sprintf('[copied] imported legacy user %s', $this->describeLegacyUser($legacyUser, $legacyId)));
|
||||
} catch (\Throwable $exception) {
|
||||
$result['errors']++;
|
||||
$message = sprintf('#%d: %s', $legacyId, $exception->getMessage());
|
||||
if (count($result['sample_error_messages']) < 10) {
|
||||
$result['sample_error_messages'][] = $message;
|
||||
}
|
||||
|
||||
$this->error('[copy-error] ' . $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function importLegacyUserBySameId(object $legacyUser, int $legacyId): void
|
||||
{
|
||||
$now = now();
|
||||
$username = $this->resolveImportUsername($legacyUser, $legacyId);
|
||||
$email = $this->resolveImportEmail($legacyUser, $legacyId);
|
||||
$name = (string) ($this->legacyField($legacyUser, 'real_name') ?: $username);
|
||||
$createdAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'joinDate')) ?? $now;
|
||||
$lastVisitAt = $this->parseLegacyDate($this->legacyField($legacyUser, 'LastVisit'));
|
||||
$countryCode = $this->legacyField($legacyUser, 'country_code');
|
||||
|
||||
DB::transaction(function () use ($legacyId, $legacyUser, $username, $email, $name, $createdAt, $lastVisitAt, $countryCode, $now): void {
|
||||
if (DB::table('users')->where('id', $legacyId)->exists()) {
|
||||
throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId));
|
||||
}
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => $legacyId,
|
||||
'username' => $username,
|
||||
'username_changed_at' => $now,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => (int) ($this->legacyField($legacyUser, 'active') ?? 1) === 1,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'last_visit_at' => $lastVisitAt,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (Schema::hasTable('user_profiles')) {
|
||||
DB::table('user_profiles')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
[
|
||||
'bio' => $this->legacyField($legacyUser, 'about_me') ?: $this->legacyField($legacyUser, 'description'),
|
||||
'country' => $this->legacyField($legacyUser, 'country'),
|
||||
'country_code' => is_string($countryCode) && $countryCode !== '' ? substr($countryCode, 0, 2) : null,
|
||||
'website' => $this->legacyField($legacyUser, 'web'),
|
||||
'gender' => $this->normalizeLegacyGender($this->legacyField($legacyUser, 'gender')),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
DB::table('user_statistics')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
[
|
||||
'uploads_count' => 0,
|
||||
'downloads_received_count' => 0,
|
||||
'artwork_views_received_count' => 0,
|
||||
'awards_received_count' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function createPlaceholderUser(int $legacyId): void
|
||||
{
|
||||
$now = now();
|
||||
$username = $this->uniquePlaceholderUsername($legacyId);
|
||||
$email = $username . '@users.skinbase.org';
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $now): void {
|
||||
if (DB::table('users')->where('id', $legacyId)->exists()) {
|
||||
throw new RuntimeException(sprintf('Conflict: user id %d already exists in the new users table.', $legacyId));
|
||||
}
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => $legacyId,
|
||||
'username' => $username,
|
||||
'username_changed_at' => $now,
|
||||
'name' => $username,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => false,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'last_visit_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (Schema::hasTable('user_profiles')) {
|
||||
DB::table('user_profiles')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
['created_at' => $now, 'updated_at' => $now],
|
||||
);
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
DB::table('user_statistics')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
[
|
||||
'uploads_count' => 0,
|
||||
'downloads_received_count' => 0,
|
||||
'artwork_views_received_count' => 0,
|
||||
'awards_received_count' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function resolveImportUsername(object $legacyUser, int $legacyId): string
|
||||
{
|
||||
$rawUsername = (string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId));
|
||||
$username = $this->sanitizeUsername($rawUsername);
|
||||
|
||||
if (! $this->usernameExists($username, $legacyId)) {
|
||||
return $username;
|
||||
}
|
||||
|
||||
return $this->uniquePlaceholderUsername($legacyId);
|
||||
}
|
||||
|
||||
private function sanitizeUsername(string $username): string
|
||||
{
|
||||
return UsernamePolicy::sanitizeLegacy($username);
|
||||
}
|
||||
|
||||
private function usernameExists(string $username, int $ignoreUserId): bool
|
||||
{
|
||||
return DB::table('users')
|
||||
->whereRaw('LOWER(username) = ?', [strtolower($username)])
|
||||
->where('id', '!=', $ignoreUserId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function uniquePlaceholderUsername(int $legacyId): string
|
||||
{
|
||||
$base = 'tmpu' . $legacyId;
|
||||
$candidate = $base;
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->usernameExists($candidate, $legacyId)) {
|
||||
$suffixStr = (string) $suffix;
|
||||
$candidate = substr($base, 0, max(1, 20 - strlen($suffixStr))) . $suffixStr;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function renderAuditSummary(array $summary, array $sampleRows): void
|
||||
{
|
||||
$this->info(sprintf(
|
||||
'Checked %d artworks: %d valid, %d missing user references, %d null user_id values.',
|
||||
(int) ($summary['checked'] ?? 0),
|
||||
(int) ($summary['valid'] ?? 0),
|
||||
(int) ($summary['missing'] ?? 0),
|
||||
(int) ($summary['null_user_ids'] ?? 0),
|
||||
));
|
||||
|
||||
if ($sampleRows !== []) {
|
||||
$this->newLine();
|
||||
$this->warn('Sample missing references:');
|
||||
$this->table(['Artwork ID', 'user_id', 'Title'], array_map(
|
||||
static fn (array $row): array => [$row['artwork_id'], $row['user_id'], $row['title']],
|
||||
$sampleRows,
|
||||
));
|
||||
}
|
||||
|
||||
if ((int) ($summary['missing'] ?? 0) === 0) {
|
||||
$this->info('No missing user references found in artworks.user_id.');
|
||||
} else {
|
||||
$this->error('Found artworks with missing user references.');
|
||||
}
|
||||
}
|
||||
|
||||
private function renderCopySummary(array $copySummary): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Legacy copy summary: requested %d users, copied %d, placeholders %d, would copy %d, conflicts %d, not found in legacy %d, errors %d.',
|
||||
(int) ($copySummary['requested_users'] ?? 0),
|
||||
(int) ($copySummary['copied'] ?? 0),
|
||||
(int) ($copySummary['placeholders_created'] ?? 0),
|
||||
(int) ($copySummary['would_copy'] ?? 0),
|
||||
(int) ($copySummary['conflicts'] ?? 0),
|
||||
(int) ($copySummary['not_found_in_legacy'] ?? 0),
|
||||
(int) ($copySummary['errors'] ?? 0),
|
||||
));
|
||||
|
||||
if (($copySummary['sample_copied_ids'] ?? []) !== []) {
|
||||
$this->line('Copied or would-copy user ids: ' . implode(', ', $copySummary['sample_copied_ids']));
|
||||
}
|
||||
|
||||
if (($copySummary['sample_placeholder_ids'] ?? []) !== []) {
|
||||
$this->line('Placeholder users created for ids: ' . implode(', ', $copySummary['sample_placeholder_ids']));
|
||||
}
|
||||
|
||||
if (($copySummary['sample_conflict_ids'] ?? []) !== []) {
|
||||
$this->warn('Conflicts: user ids already present in new DB: ' . implode(', ', $copySummary['sample_conflict_ids']));
|
||||
}
|
||||
|
||||
if (($copySummary['sample_not_found_ids'] ?? []) !== []) {
|
||||
$this->warn('Not found in legacy: ' . implode(', ', $copySummary['sample_not_found_ids']));
|
||||
}
|
||||
|
||||
if (($copySummary['sample_error_messages'] ?? []) !== []) {
|
||||
foreach ($copySummary['sample_error_messages'] as $message) {
|
||||
$this->warn($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureLegacyConnectionIsUsable(string $connection, string $table): void
|
||||
{
|
||||
try {
|
||||
DB::connection($connection)->getPdo();
|
||||
} catch (\Throwable $exception) {
|
||||
throw new RuntimeException(sprintf('Legacy DB connection "%s" is not configured or reachable.', $connection), 0, $exception);
|
||||
}
|
||||
|
||||
if (! DB::connection($connection)->getSchemaBuilder()->hasTable($table)) {
|
||||
throw new RuntimeException(sprintf('Legacy users table "%s" was not found on connection "%s".', $table, $connection));
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveImportEmail(object $legacyUser, int $legacyId): string
|
||||
{
|
||||
$rawEmail = strtolower(trim((string) ($this->legacyField($legacyUser, 'email') ?? '')));
|
||||
$candidate = $rawEmail !== ''
|
||||
? $rawEmail
|
||||
: ($this->sanitizeEmailLocal((string) ($this->legacyField($legacyUser, 'uname') ?: ('user' . $legacyId))) . '@users.skinbase.org');
|
||||
|
||||
return $this->uniqueEmailCandidate($candidate, $legacyId);
|
||||
}
|
||||
|
||||
private function uniqueEmailCandidate(string $email, int $legacyId): string
|
||||
{
|
||||
$candidate = strtolower(trim($email));
|
||||
$suffix = 1;
|
||||
|
||||
while ($candidate === '' || DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->where('id', '!=', $legacyId)->exists()) {
|
||||
$parts = explode('@', $email, 2);
|
||||
$local = $this->sanitizeEmailLocal($parts[0] ?? ('user' . $legacyId));
|
||||
$domain = $parts[1] ?? 'users.skinbase.org';
|
||||
$candidate = $local . '+' . $suffix . '@' . $domain;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim(Str::ascii($value)));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
|
||||
private function normalizeLegacyGender(mixed $value): ?string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) ($value ?? '')));
|
||||
|
||||
return match ($normalized) {
|
||||
'M', 'MALE', 'MAN', 'BOY' => 'M',
|
||||
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function isVerboseOutput(): bool
|
||||
{
|
||||
return $this->getOutput()->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE;
|
||||
}
|
||||
|
||||
private function describeLegacyUser(object $legacyUser, int $legacyId): string
|
||||
{
|
||||
$username = trim((string) ($this->legacyField($legacyUser, 'uname') ?? ''));
|
||||
$name = trim((string) ($this->legacyField($legacyUser, 'real_name') ?? ''));
|
||||
$email = trim((string) ($this->legacyField($legacyUser, 'email') ?? ''));
|
||||
|
||||
return sprintf(
|
||||
'#%d username=%s name=%s email=%s',
|
||||
$legacyId,
|
||||
$username !== '' ? '@' . $username : '[missing]',
|
||||
$name !== '' ? '"' . $name . '"' : '[missing]',
|
||||
$email !== '' ? '<' . $email . '>' : '[missing]',
|
||||
);
|
||||
}
|
||||
|
||||
private function parseLegacyDate(mixed $value): ?Carbon
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '' || str_starts_with($value, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function legacyField(object $row, string $field): mixed
|
||||
{
|
||||
return property_exists($row, $field) ? $row->{$field} : null;
|
||||
}
|
||||
}
|
||||
88
app/Console/Commands/PublishScheduledNewsCommand.php
Normal file
88
app/Console/Commands/PublishScheduledNewsCommand.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class PublishScheduledNewsCommand extends Command
|
||||
{
|
||||
protected $signature = 'news:publish-scheduled
|
||||
{--dry-run : List scheduled articles without publishing}
|
||||
{--limit=100 : Max articles to process per run}';
|
||||
|
||||
protected $description = 'Publish scheduled News articles whose publish time has passed.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
$now = now()->utc();
|
||||
|
||||
$candidates = NewsArticle::query()
|
||||
->where('editorial_status', NewsArticle::EDITORIAL_STATUS_SCHEDULED)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', $now)
|
||||
->orderBy('published_at')
|
||||
->limit($limit)
|
||||
->get(['id', 'title', 'published_at']);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->line('No scheduled News articles due for publishing.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$published = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf('[dry-run] Would publish News article #%d: "%s"', $candidate->id, $candidate->title));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($candidate, $now, &$published): void {
|
||||
$article = NewsArticle::query()
|
||||
->lockForUpdate()
|
||||
->where('id', $candidate->id)
|
||||
->where('editorial_status', NewsArticle::EDITORIAL_STATUS_SCHEDULED)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', $now)
|
||||
->first();
|
||||
|
||||
if (! $article) {
|
||||
return;
|
||||
}
|
||||
|
||||
$article->forceFill([
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
|
||||
'status' => 'published',
|
||||
'published_at' => $article->published_at ?? $now,
|
||||
])->save();
|
||||
|
||||
$published++;
|
||||
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
$errors++;
|
||||
Log::error('PublishScheduledNewsCommand failed', [
|
||||
'article_id' => $candidate->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$this->error(sprintf('Failed to publish News article #%d: %s', $candidate->id, $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info(sprintf('Done. Published: %d, Errors: %d.', $published, $errors));
|
||||
}
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ use App\Jobs\RankBuildListsJob;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
use App\Console\Commands\NormalizeArtworkSlugsCommand;
|
||||
use App\Console\Commands\PublishScheduledArtworksCommand;
|
||||
use App\Console\Commands\PublishScheduledNewsCommand;
|
||||
use App\Console\Commands\PublishScheduledNovaCardsCommand;
|
||||
use App\Console\Commands\BuildSitemapsCommand;
|
||||
use App\Console\Commands\ListSitemapReleasesCommand;
|
||||
@@ -62,6 +63,7 @@ class Kernel extends ConsoleKernel
|
||||
RollbackSitemapReleaseCommand::class,
|
||||
NormalizeArtworkSlugsCommand::class,
|
||||
PublishScheduledArtworksCommand::class,
|
||||
PublishScheduledNewsCommand::class,
|
||||
PublishScheduledNovaCardsCommand::class,
|
||||
SyncCollectionLifecycleCommand::class,
|
||||
ValidateSitemapsCommand::class,
|
||||
@@ -115,6 +117,11 @@ class Kernel extends ConsoleKernel
|
||||
->name('publish-scheduled-artworks')
|
||||
->withoutOverlapping(2) // prevent overlap up to 2 minutes
|
||||
->runInBackground();
|
||||
$schedule->command('news:publish-scheduled')
|
||||
->everyMinute()
|
||||
->name('publish-scheduled-news')
|
||||
->withoutOverlapping(2)
|
||||
->runInBackground();
|
||||
$schedule->command('nova-cards:publish-scheduled')
|
||||
->everyMinute()
|
||||
->name('publish-scheduled-nova-cards')
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
35
app/Http/Controllers/Api/Search/GroupSearchController.php
Normal file
35
app/Http/Controllers/Api/Search/GroupSearchController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
27
app/Http/Controllers/GroupAssetController.php
Normal file
27
app/Http/Controllers/GroupAssetController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/GroupChallengeController.php
Normal file
74
app/Http/Controllers/GroupChallengeController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/GroupController.php
Normal file
143
app/Http/Controllers/GroupController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/GroupEngagementController.php
Normal file
44
app/Http/Controllers/GroupEngagementController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/GroupEventController.php
Normal file
62
app/Http/Controllers/GroupEventController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/GroupJoinRequestController.php
Normal file
53
app/Http/Controllers/GroupJoinRequestController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
154
app/Http/Controllers/GroupMemberController.php
Normal file
154
app/Http/Controllers/GroupMemberController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/GroupPostController.php
Normal file
86
app/Http/Controllers/GroupPostController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/GroupProjectController.php
Normal file
59
app/Http/Controllers/GroupProjectController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/GroupReleaseController.php
Normal file
60
app/Http/Controllers/GroupReleaseController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Studio/GroupAssetStudioController.php
Normal file
63
app/Http/Controllers/Studio/GroupAssetStudioController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
126
app/Http/Controllers/Studio/GroupChallengeStudioController.php
Normal file
126
app/Http/Controllers/Studio/GroupChallengeStudioController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/Studio/GroupEventStudioController.php
Normal file
110
app/Http/Controllers/Studio/GroupEventStudioController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
133
app/Http/Controllers/Studio/GroupPostStudioController.php
Normal file
133
app/Http/Controllers/Studio/GroupPostStudioController.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/Studio/GroupProjectStudioController.php
Normal file
167
app/Http/Controllers/Studio/GroupProjectStudioController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
177
app/Http/Controllers/Studio/GroupReleaseStudioController.php
Normal file
177
app/Http/Controllers/Studio/GroupReleaseStudioController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Studio/GroupReviewStudioController.php
Normal file
62
app/Http/Controllers/Studio/GroupReviewStudioController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
235
app/Http/Controllers/Studio/GroupStudioController.php
Normal file
235
app/Http/Controllers/Studio/GroupStudioController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
403
app/Http/Controllers/Studio/StudioNewsController.php
Normal file
403
app/Http/Controllers/Studio/StudioNewsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
52
app/Http/Controllers/Web/AccountHelpPageController.php
Normal file
52
app/Http/Controllers/Web/AccountHelpPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
53
app/Http/Controllers/Web/AuthHelpPageController.php
Normal file
53
app/Http/Controllers/Web/AuthHelpPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Web/CardsHelpPageController.php
Normal file
50
app/Http/Controllers/Web/CardsHelpPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/GroupFaqPageController.php
Normal file
47
app/Http/Controllers/Web/GroupFaqPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/GroupHelpPageController.php
Normal file
47
app/Http/Controllers/Web/GroupHelpPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Web/GroupQuickstartPageController.php
Normal file
45
app/Http/Controllers/Web/GroupQuickstartPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/Web/HelpCenterPageController.php
Normal file
68
app/Http/Controllers/Web/HelpCenterPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
51
app/Http/Controllers/Web/ProfileHelpPageController.php
Normal file
51
app/Http/Controllers/Web/ProfileHelpPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
60
app/Http/Controllers/Web/StudioHelpPageController.php
Normal file
60
app/Http/Controllers/Web/StudioHelpPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Web/UploadHelpPageController.php
Normal file
53
app/Http/Controllers/Web/UploadHelpPageController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\GroupService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -62,11 +63,30 @@ final class HandleInertiaRequests extends Middleware
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
'is_admin' => $request->user()->isAdmin(),
|
||||
'is_moderator' => $request->user()->isModerator(),
|
||||
] : null,
|
||||
],
|
||||
'cdn' => [
|
||||
'files_url' => config('cdn.files_url'),
|
||||
],
|
||||
'features' => [
|
||||
'groups' => (bool) config('features.groups', true),
|
||||
'groups_v1' => (bool) config('features.groups_v1', true),
|
||||
'groups_v2' => (bool) config('features.groups_v2', true),
|
||||
'group_posts' => (bool) config('features.group_posts', true),
|
||||
'group_recruitment' => (bool) config('features.group_recruitment', true),
|
||||
'group_join_requests' => (bool) config('features.group_join_requests', true),
|
||||
'group_review_queue' => (bool) config('features.group_review_queue', true),
|
||||
'group_projects' => (bool) config('features.group_projects', true),
|
||||
'group_challenges' => (bool) config('features.group_challenges', true),
|
||||
'group_events' => (bool) config('features.group_events', true),
|
||||
'group_assets' => (bool) config('features.group_assets', true),
|
||||
'group_activity_feed' => (bool) config('features.group_activity_feed', true),
|
||||
],
|
||||
'studio_groups' => $request->user()
|
||||
? app(GroupService::class)->studioOptionsForUser($request->user())
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ final class ArtworkCreateRequest extends FormRequest
|
||||
'tags' => 'nullable|string|max:200',
|
||||
'license' => 'nullable|boolean',
|
||||
'is_mature' => 'nullable|boolean',
|
||||
'group' => 'nullable|string|max:90',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class StoreCollectionRequest extends FormRequest
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:120'],
|
||||
'slug' => ['required', 'string', 'min:2', 'max:140', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'],
|
||||
'group' => ['nullable', 'string', 'max:90'],
|
||||
'type' => ['nullable', 'in:' . implode(',', [
|
||||
Collection::TYPE_PERSONAL,
|
||||
Collection::TYPE_COMMUNITY,
|
||||
|
||||
@@ -45,6 +45,7 @@ class UpdateCollectionRequest extends FormRequest
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:120'],
|
||||
'slug' => ['required', 'string', 'min:2', 'max:140', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'],
|
||||
'group' => ['nullable', 'string', 'max:90'],
|
||||
'type' => ['nullable', 'in:' . implode(',', [
|
||||
Collection::TYPE_PERSONAL,
|
||||
Collection::TYPE_COMMUNITY,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachArtworkToGroupChallengeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachArtworkToGroupProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachArtworkToGroupReleaseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'artwork_id' => ['required', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachAssetToGroupProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'asset_id' => ['required', 'integer', 'exists:group_assets,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AttachContributorToGroupReleaseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => ['required', 'integer'],
|
||||
'role_label' => ['nullable', 'string', 'max:80'],
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/Groups/PinGroupActivityItemRequest.php
Normal file
22
app/Http/Requests/Groups/PinGroupActivityItemRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PinGroupActivityItemRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'is_pinned' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/Groups/ReviewGroupArtworkRequest.php
Normal file
22
app/Http/Requests/Groups/ReviewGroupArtworkRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReviewGroupArtworkRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'review_notes' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Groups/ReviewGroupJoinRequestRequest.php
Normal file
24
app/Http/Requests/Groups/ReviewGroupJoinRequestRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReviewGroupJoinRequestRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'role' => ['nullable', 'in:' . implode(',', [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER, Group::ROLE_CONTRIBUTOR])],
|
||||
'review_notes' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Groups/StoreGroupAssetRequest.php
Normal file
29
app/Http/Requests/Groups/StoreGroupAssetRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupAssetRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'description' => ['nullable', 'string', 'max:40000'],
|
||||
'category' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.categories', []))],
|
||||
'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.visibility_options', []))],
|
||||
'status' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.statuses', []))],
|
||||
'linked_project_id' => ['nullable', 'integer'],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
'file' => ['required', 'file', 'mimes:' . implode(',', (array) config('groups.assets.allowed_extensions', [])), 'max:' . (int) config('groups.assets.max_upload_kb', 20480)],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/Groups/StoreGroupChallengeRequest.php
Normal file
37
app/Http/Requests/Groups/StoreGroupChallengeRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupChallengeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'summary' => ['nullable', 'string', 'max:320'],
|
||||
'description' => ['nullable', 'string', 'max:40000'],
|
||||
'cover_path' => ['nullable', 'string', 'max:2048'],
|
||||
'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.visibility_options', []))],
|
||||
'participation_scope' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.participation_scopes', []))],
|
||||
'status' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.statuses', []))],
|
||||
'start_at' => ['nullable', 'date'],
|
||||
'end_at' => ['nullable', 'date', 'after_or_equal:start_at'],
|
||||
'rules_text' => ['nullable', 'string', 'max:20000'],
|
||||
'submission_instructions' => ['nullable', 'string', 'max:20000'],
|
||||
'judging_mode' => ['nullable', 'in:' . implode(',', (array) config('groups.challenges.judging_modes', []))],
|
||||
'linked_collection_id' => ['nullable', 'integer'],
|
||||
'linked_project_id' => ['nullable', 'integer'],
|
||||
'featured_artwork_id' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/Groups/StoreGroupEventRequest.php
Normal file
38
app/Http/Requests/Groups/StoreGroupEventRequest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'summary' => ['nullable', 'string', 'max:320'],
|
||||
'description' => ['nullable', 'string', 'max:40000'],
|
||||
'event_type' => ['nullable', 'in:' . implode(',', (array) config('groups.events.types', []))],
|
||||
'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.events.visibility_options', []))],
|
||||
'status' => ['nullable', 'in:' . implode(',', (array) config('groups.events.statuses', []))],
|
||||
'start_at' => ['nullable', 'date'],
|
||||
'end_at' => ['nullable', 'date', 'after_or_equal:start_at'],
|
||||
'timezone' => ['nullable', 'string', 'max:80'],
|
||||
'cover_path' => ['nullable', 'string', 'max:2048'],
|
||||
'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'location' => ['nullable', 'string', 'max:180'],
|
||||
'external_url' => ['nullable', 'url', 'max:2048'],
|
||||
'linked_project_id' => ['nullable', 'integer'],
|
||||
'linked_collection_id' => ['nullable', 'integer'],
|
||||
'linked_challenge_id' => ['nullable', 'integer'],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Groups/StoreGroupJoinRequestRequest.php
Normal file
26
app/Http/Requests/Groups/StoreGroupJoinRequestRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupJoinRequestRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'message' => ['nullable', 'string', 'max:2000'],
|
||||
'portfolio_url' => ['nullable', 'url', 'max:2048'],
|
||||
'desired_role' => ['nullable', 'string', 'max:32'],
|
||||
'skills_json' => ['nullable', 'array', 'max:12'],
|
||||
'skills_json.*' => ['string', 'max:80'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Groups/StoreGroupMemberRequest.php
Normal file
26
app/Http/Requests/Groups/StoreGroupMemberRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupMemberRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['required', 'string', 'max:20'],
|
||||
'role' => ['required', 'in:' . implode(',', [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER, Group::ROLE_CONTRIBUTOR])],
|
||||
'note' => ['nullable', 'string', 'max:500'],
|
||||
'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:30'],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/Groups/StoreGroupMilestoneRequest.php
Normal file
27
app/Http/Requests/Groups/StoreGroupMilestoneRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupMilestoneRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'summary' => ['nullable', 'string', 'max:320'],
|
||||
'status' => ['nullable', 'in:' . implode(',', (array) config('groups.milestones.statuses', []))],
|
||||
'due_date' => ['nullable', 'date'],
|
||||
'owner_user_id' => ['nullable', 'integer'],
|
||||
'notes' => ['nullable', 'string', 'max:40000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Groups/StoreGroupPostRequest.php
Normal file
32
app/Http/Requests/Groups/StoreGroupPostRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\GroupPost;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupPostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'in:' . implode(',', [
|
||||
GroupPost::TYPE_ANNOUNCEMENT,
|
||||
GroupPost::TYPE_RELEASE,
|
||||
GroupPost::TYPE_RECRUITMENT,
|
||||
GroupPost::TYPE_UPDATE,
|
||||
])],
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'excerpt' => ['nullable', 'string', 'max:320'],
|
||||
'content' => ['nullable', 'string', 'max:40000'],
|
||||
'cover_path' => ['nullable', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Groups/StoreGroupProjectRequest.php
Normal file
36
app/Http/Requests/Groups/StoreGroupProjectRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'summary' => ['nullable', 'string', 'max:320'],
|
||||
'description' => ['nullable', 'string', 'max:40000'],
|
||||
'cover_path' => ['nullable', 'string', 'max:2048'],
|
||||
'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'status' => ['nullable', 'in:' . implode(',', (array) config('groups.projects.statuses', []))],
|
||||
'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.projects.visibility_options', []))],
|
||||
'start_date' => ['nullable', 'date'],
|
||||
'target_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'lead_user_id' => ['nullable', 'integer'],
|
||||
'linked_collection_id' => ['nullable', 'integer'],
|
||||
'linked_featured_artwork_id' => ['nullable', 'integer'],
|
||||
'pinned_post_id' => ['nullable', 'integer'],
|
||||
'member_user_ids' => ['nullable', 'array'],
|
||||
'member_user_ids.*' => ['integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Groups/StoreGroupReleaseRequest.php
Normal file
36
app/Http/Requests/Groups/StoreGroupReleaseRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreGroupReleaseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'summary' => ['nullable', 'string', 'max:320'],
|
||||
'description' => ['nullable', 'string', 'max:40000'],
|
||||
'cover_path' => ['nullable', 'string', 'max:2048'],
|
||||
'cover_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'status' => ['nullable', 'in:' . implode(',', (array) config('groups.releases.statuses', []))],
|
||||
'current_stage' => ['nullable', 'in:' . implode(',', (array) config('groups.releases.stages', []))],
|
||||
'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.releases.visibility_options', []))],
|
||||
'planned_release_at' => ['nullable', 'date'],
|
||||
'lead_user_id' => ['nullable', 'integer'],
|
||||
'linked_project_id' => ['nullable', 'integer'],
|
||||
'linked_collection_id' => ['nullable', 'integer'],
|
||||
'featured_artwork_id' => ['nullable', 'integer'],
|
||||
'release_notes' => ['nullable', 'string', 'max:40000'],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/Groups/StoreGroupRequest.php
Normal file
53
app/Http/Requests/Groups/StoreGroupRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StoreGroupRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$name = (string) $this->input('name', '');
|
||||
$slug = (string) $this->input('slug', '');
|
||||
|
||||
if ($slug === '' && $name !== '') {
|
||||
$slug = Str::slug(Str::limit($name, 90, ''));
|
||||
}
|
||||
|
||||
$this->merge([
|
||||
'slug' => $slug,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'min:2', 'max:80'],
|
||||
'slug' => ['required', 'string', 'min:2', 'max:90', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'],
|
||||
'headline' => ['nullable', 'string', 'max:160'],
|
||||
'bio' => ['nullable', 'string', 'max:3000'],
|
||||
'visibility' => ['required', 'in:' . implode(',', Group::acceptedVisibilityValues())],
|
||||
'membership_policy' => ['nullable', 'in:' . implode(',', Group::acceptedMembershipPolicies())],
|
||||
'type' => ['nullable', 'string', 'max:80'],
|
||||
'founded_at' => ['nullable', 'date'],
|
||||
'website_url' => ['nullable', 'url', 'max:2048'],
|
||||
'links_json' => ['nullable', 'array', 'max:8'],
|
||||
'links_json.*.label' => ['required_with:links_json', 'string', 'max:40'],
|
||||
'links_json.*.url' => ['required_with:links_json', 'url', 'max:2048'],
|
||||
'avatar_path' => ['nullable', 'string', 'max:2048'],
|
||||
'banner_path' => ['nullable', 'string', 'max:2048'],
|
||||
'avatar_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'],
|
||||
'banner_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Groups/UpdateGroupAssetRequest.php
Normal file
28
app/Http/Requests/Groups/UpdateGroupAssetRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateGroupAssetRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'min:2', 'max:180'],
|
||||
'description' => ['nullable', 'string', 'max:40000'],
|
||||
'category' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.categories', []))],
|
||||
'visibility' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.visibility_options', []))],
|
||||
'status' => ['nullable', 'in:' . implode(',', (array) config('groups.assets.statuses', []))],
|
||||
'linked_project_id' => ['nullable', 'integer'],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Http/Requests/Groups/UpdateGroupChallengeRequest.php
Normal file
9
app/Http/Requests/Groups/UpdateGroupChallengeRequest.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
class UpdateGroupChallengeRequest extends StoreGroupChallengeRequest
|
||||
{
|
||||
}
|
||||
9
app/Http/Requests/Groups/UpdateGroupEventRequest.php
Normal file
9
app/Http/Requests/Groups/UpdateGroupEventRequest.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
class UpdateGroupEventRequest extends StoreGroupEventRequest
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateGroupMemberPermissionsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'permission_overrides' => ['required', 'array'],
|
||||
'permission_overrides.*.key' => ['required', 'string', Rule::in(Group::permissionKeys())],
|
||||
'permission_overrides.*.is_allowed' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/Groups/UpdateGroupMemberRequest.php
Normal file
23
app/Http/Requests/Groups/UpdateGroupMemberRequest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateGroupMemberRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'role' => ['required', 'in:' . implode(',', [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER, Group::ROLE_CONTRIBUTOR])],
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Http/Requests/Groups/UpdateGroupMilestoneRequest.php
Normal file
9
app/Http/Requests/Groups/UpdateGroupMilestoneRequest.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
class UpdateGroupMilestoneRequest extends StoreGroupMilestoneRequest
|
||||
{
|
||||
}
|
||||
32
app/Http/Requests/Groups/UpdateGroupPostRequest.php
Normal file
32
app/Http/Requests/Groups/UpdateGroupPostRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\GroupPost;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateGroupPostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['sometimes', 'in:' . implode(',', [
|
||||
GroupPost::TYPE_ANNOUNCEMENT,
|
||||
GroupPost::TYPE_RELEASE,
|
||||
GroupPost::TYPE_RECRUITMENT,
|
||||
GroupPost::TYPE_UPDATE,
|
||||
])],
|
||||
'title' => ['sometimes', 'string', 'min:2', 'max:180'],
|
||||
'excerpt' => ['nullable', 'string', 'max:320'],
|
||||
'content' => ['nullable', 'string', 'max:40000'],
|
||||
'cover_path' => ['nullable', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Http/Requests/Groups/UpdateGroupProjectRequest.php
Normal file
9
app/Http/Requests/Groups/UpdateGroupProjectRequest.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
class UpdateGroupProjectRequest extends StoreGroupProjectRequest
|
||||
{
|
||||
}
|
||||
22
app/Http/Requests/Groups/UpdateGroupProjectStatusRequest.php
Normal file
22
app/Http/Requests/Groups/UpdateGroupProjectStatusRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateGroupProjectStatusRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', 'in:' . implode(',', (array) config('groups.projects.statuses', []))],
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Http/Requests/Groups/UpdateGroupRecruitmentRequest.php
Normal file
35
app/Http/Requests/Groups/UpdateGroupRecruitmentRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateGroupRecruitmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$allowedRoles = config('groups.recruitment.roles', []);
|
||||
$allowedSkills = config('groups.recruitment.skills', []);
|
||||
$allowedContactModes = config('groups.recruitment.contact_modes', ['join_request', 'direct_message', 'external_link']);
|
||||
$allowedVisibility = config('groups.recruitment.visibility_options', ['public', 'members_only', 'private']);
|
||||
|
||||
return [
|
||||
'is_recruiting' => ['required', 'boolean'],
|
||||
'headline' => ['nullable', 'string', 'max:180'],
|
||||
'description' => ['nullable', 'string', 'max:4000'],
|
||||
'roles_json' => ['nullable', 'array', 'max:12'],
|
||||
'roles_json.*' => ['string', 'max:80', 'in:' . implode(',', $allowedRoles)],
|
||||
'skills_json' => ['nullable', 'array', 'max:20'],
|
||||
'skills_json.*' => ['string', 'max:80', 'in:' . implode(',', $allowedSkills)],
|
||||
'contact_mode' => ['nullable', 'string', 'max:32', 'in:' . implode(',', $allowedContactModes)],
|
||||
'visibility' => ['nullable', 'in:' . implode(',', $allowedVisibility)],
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Http/Requests/Groups/UpdateGroupReleaseRequest.php
Normal file
9
app/Http/Requests/Groups/UpdateGroupReleaseRequest.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
class UpdateGroupReleaseRequest extends StoreGroupReleaseRequest
|
||||
{
|
||||
}
|
||||
22
app/Http/Requests/Groups/UpdateGroupReleaseStageRequest.php
Normal file
22
app/Http/Requests/Groups/UpdateGroupReleaseStageRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateGroupReleaseStageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_stage' => ['required', 'in:' . implode(',', (array) config('groups.releases.stages', []))],
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/Groups/UpdateGroupRequest.php
Normal file
54
app/Http/Requests/Groups/UpdateGroupRequest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Groups;
|
||||
|
||||
use App\Models\Group;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UpdateGroupRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$name = (string) $this->input('name', '');
|
||||
$slug = (string) $this->input('slug', '');
|
||||
|
||||
if ($slug === '' && $name !== '') {
|
||||
$slug = Str::slug(Str::limit($name, 90, ''));
|
||||
}
|
||||
|
||||
$this->merge([
|
||||
'slug' => $slug,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'min:2', 'max:80'],
|
||||
'slug' => ['required', 'string', 'min:2', 'max:90', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'],
|
||||
'headline' => ['nullable', 'string', 'max:160'],
|
||||
'bio' => ['nullable', 'string', 'max:3000'],
|
||||
'visibility' => ['required', 'in:' . implode(',', Group::acceptedVisibilityValues())],
|
||||
'membership_policy' => ['nullable', 'in:' . implode(',', Group::acceptedMembershipPolicies())],
|
||||
'type' => ['nullable', 'string', 'max:80'],
|
||||
'founded_at' => ['nullable', 'date'],
|
||||
'website_url' => ['nullable', 'url', 'max:2048'],
|
||||
'links_json' => ['nullable', 'array', 'max:8'],
|
||||
'links_json.*.label' => ['required_with:links_json', 'string', 'max:40'],
|
||||
'links_json.*.url' => ['required_with:links_json', 'url', 'max:2048'],
|
||||
'avatar_path' => ['nullable', 'string', 'max:2048'],
|
||||
'banner_path' => ['nullable', 'string', 'max:2048'],
|
||||
'avatar_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'],
|
||||
'banner_file' => ['nullable', 'file', 'image', 'max:5120', 'mimes:jpg,jpeg,png,webp'],
|
||||
'featured_artwork_id' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ class ArtworkResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
|
||||
$md = ThumbnailPresenter::present($this->resource, 'md');
|
||||
$lg = ThumbnailPresenter::present($this->resource, 'lg');
|
||||
$xl = ThumbnailPresenter::present($this->resource, 'xl');
|
||||
@@ -46,6 +48,7 @@ class ArtworkResource extends JsonResource
|
||||
$isFavorited = false;
|
||||
$isBookmarked = false;
|
||||
$isFollowing = false;
|
||||
$isFollowingGroup = false;
|
||||
$viewerAward = null;
|
||||
|
||||
$bookmarksCount = Schema::hasTable('artwork_bookmarks')
|
||||
@@ -87,6 +90,13 @@ class ArtworkResource extends JsonResource
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($this->group?->id) && Schema::hasTable('group_follows')) {
|
||||
$isFollowingGroup = DB::table('group_follows')
|
||||
->where('group_id', (int) $this->group->id)
|
||||
->where('user_id', $viewerId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('artwork_awards')) {
|
||||
$viewerAward = DB::table('artwork_awards')
|
||||
->where('user_id', $viewerId)
|
||||
@@ -96,6 +106,62 @@ class ArtworkResource extends JsonResource
|
||||
}
|
||||
|
||||
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$mapUser = static function ($user): ?array {
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) ($user->id ?? 0),
|
||||
'name' => html_entity_decode((string) ($user->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'username' => (string) ($user->username ?? ''),
|
||||
'profile_url' => ! empty($user->username) ? '/@' . $user->username : null,
|
||||
'avatar_url' => $user->profile?->avatar_url,
|
||||
];
|
||||
};
|
||||
|
||||
$publisher = $this->group
|
||||
? [
|
||||
'type' => 'group',
|
||||
'id' => (int) $this->group->id,
|
||||
'name' => (string) $this->group->name,
|
||||
'slug' => (string) $this->group->slug,
|
||||
'headline' => (string) ($this->group->headline ?? ''),
|
||||
'avatar_url' => $this->group->avatarUrl(),
|
||||
'profile_url' => $this->group->publicUrl(),
|
||||
'followers_count' => (int) ($this->group->followers_count ?? 0),
|
||||
'follow_url' => route('groups.follow', ['group' => $this->group]),
|
||||
'unfollow_url' => route('groups.unfollow', ['group' => $this->group]),
|
||||
]
|
||||
: [
|
||||
'type' => 'user',
|
||||
'id' => (int) ($this->user?->id ?? 0),
|
||||
'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'slug' => (string) ($this->user?->username ?? ''),
|
||||
'headline' => '',
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'followers_count' => $followerCount,
|
||||
'follow_url' => null,
|
||||
'unfollow_url' => null,
|
||||
];
|
||||
|
||||
$primaryAuthor = $mapUser($this->primaryAuthor ?: $this->user);
|
||||
$uploadedBy = $mapUser($this->uploadedBy ?: $this->user);
|
||||
$contributors = $this->contributors
|
||||
->map(function ($contributor) use ($mapUser): ?array {
|
||||
$user = $mapUser($contributor->user);
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_merge($user, [
|
||||
'credit_role' => $contributor->credit_role,
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
]);
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return [
|
||||
'id' => (int) $this->id,
|
||||
@@ -131,11 +197,19 @@ class ArtworkResource extends JsonResource
|
||||
'rank' => (string) ($this->user?->rank ?? 'Newbie'),
|
||||
'followers_count' => $followerCount,
|
||||
],
|
||||
'publisher' => $publisher,
|
||||
'credits' => [
|
||||
'uploaded_by' => $uploadedBy,
|
||||
'primary_author' => $primaryAuthor,
|
||||
'contributors' => $contributors,
|
||||
],
|
||||
'viewer' => [
|
||||
'is_bookmarked' => $isBookmarked,
|
||||
'is_liked' => $isLiked,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_following_author' => $isFollowing,
|
||||
'is_following_group' => $isFollowingGroup,
|
||||
'is_following_publisher' => $publisher['type'] === 'group' ? $isFollowingGroup : $isFollowing,
|
||||
'is_authenticated' => $viewerId > 0,
|
||||
'id' => $viewerId > 0 ? $viewerId : null,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\ArtworkContributor;
|
||||
use App\Models\Group;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -26,6 +28,9 @@ class Artwork extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, Searchable;
|
||||
|
||||
public const PUBLISHED_AS_USER = 'user';
|
||||
public const PUBLISHED_AS_GROUP = 'group';
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
public const VISIBILITY_UNLISTED = 'unlisted';
|
||||
public const VISIBILITY_PRIVATE = 'private';
|
||||
@@ -34,6 +39,11 @@ class Artwork extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'group_id',
|
||||
'uploaded_by_user_id',
|
||||
'primary_author_user_id',
|
||||
'published_as_type',
|
||||
'published_as_id',
|
||||
'title',
|
||||
'slug',
|
||||
'description',
|
||||
@@ -81,6 +91,8 @@ class Artwork extends Model
|
||||
'is_approved' => 'boolean',
|
||||
'is_mature' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'published_as_type' => 'string',
|
||||
'published_as_id' => 'integer',
|
||||
'publish_at' => 'datetime',
|
||||
'clip_tags_json' => 'array',
|
||||
'yolo_objects_json' => 'array',
|
||||
@@ -167,6 +179,51 @@ class Artwork extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function uploadedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by_user_id');
|
||||
}
|
||||
|
||||
public function primaryAuthor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'primary_author_user_id');
|
||||
}
|
||||
|
||||
public function contributors(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function isPublishedByGroup(): bool
|
||||
{
|
||||
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP;
|
||||
}
|
||||
|
||||
public function publishedAsType(): string
|
||||
{
|
||||
if (in_array($this->published_as_type, [self::PUBLISHED_AS_USER, self::PUBLISHED_AS_GROUP], true)) {
|
||||
return (string) $this->published_as_type;
|
||||
}
|
||||
|
||||
return (int) ($this->group_id ?? 0) > 0 ? self::PUBLISHED_AS_GROUP : self::PUBLISHED_AS_USER;
|
||||
}
|
||||
|
||||
public function publishedAsId(): int
|
||||
{
|
||||
if ((int) ($this->published_as_id ?? 0) > 0) {
|
||||
return (int) $this->published_as_id;
|
||||
}
|
||||
|
||||
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP
|
||||
? (int) ($this->group_id ?? 0)
|
||||
: (int) $this->user_id;
|
||||
}
|
||||
|
||||
public function translations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkTranslation::class);
|
||||
@@ -268,7 +325,7 @@ class Artwork extends Model
|
||||
*/
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
$this->loadMissing(['user', 'tags', 'categories.contentType', 'stats', 'awardStat']);
|
||||
$this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']);
|
||||
|
||||
$stat = $this->stats;
|
||||
$awardStat = $this->awardStat;
|
||||
@@ -301,8 +358,9 @@ class Artwork extends Model
|
||||
'slug' => $this->slug,
|
||||
'title' => $this->title,
|
||||
'description' => (string) ($this->description ?? ''),
|
||||
'author_id' => $this->user_id,
|
||||
'author_name' => $this->user?->name ?? 'Skinbase',
|
||||
'author_id' => $this->publishedAsId(),
|
||||
'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase',
|
||||
'published_as_type' => $this->publishedAsType(),
|
||||
'category' => $category,
|
||||
'content_type' => $content_type,
|
||||
'tags' => $tags,
|
||||
|
||||
37
app/Models/ArtworkContributor.php
Normal file
37
app/Models/ArtworkContributor.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ArtworkContributor extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'credit_role',
|
||||
'is_primary',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Models\CollectionMember;
|
||||
use App\Models\CollectionSave;
|
||||
use App\Models\CollectionSurfacePlacement;
|
||||
use App\Models\CollectionSubmission;
|
||||
use App\Models\Group;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -120,6 +121,7 @@ class Collection extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'group_id',
|
||||
'managed_by_user_id',
|
||||
'title',
|
||||
'slug',
|
||||
@@ -263,6 +265,11 @@ class Collection extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function managedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'managed_by_user_id');
|
||||
@@ -448,7 +455,17 @@ class Collection extends Model
|
||||
{
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
return $userId !== null && (int) $userId === (int) $this->user_id;
|
||||
if ($userId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) ($this->group_id ?? 0) > 0) {
|
||||
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
|
||||
|
||||
return $group?->hasActiveMember((int) $userId) ?? false;
|
||||
}
|
||||
|
||||
return (int) $userId === (int) $this->user_id;
|
||||
}
|
||||
|
||||
public function isPubliclyAccessible(): bool
|
||||
@@ -488,6 +505,12 @@ class Collection extends Model
|
||||
|
||||
public function displayOwnerName(): string
|
||||
{
|
||||
if ((int) ($this->group_id ?? 0) > 0) {
|
||||
$group = $this->relationLoaded('group') ? $this->group : $this->group()->first();
|
||||
|
||||
return (string) ($group?->name ?: 'Skinbase Group');
|
||||
}
|
||||
|
||||
if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) {
|
||||
return (string) ($this->editorial_owner_label ?: config('collections.editorial.system_owner_label', 'Skinbase Editorial'));
|
||||
}
|
||||
@@ -499,6 +522,10 @@ class Collection extends Model
|
||||
|
||||
public function displayOwnerUsername(): ?string
|
||||
{
|
||||
if ((int) ($this->group_id ?? 0) > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) {
|
||||
return null;
|
||||
}
|
||||
@@ -526,6 +553,12 @@ class Collection extends Model
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) ($this->group_id ?? 0) > 0) {
|
||||
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
|
||||
|
||||
return $group?->activeRoleFor((int) $userId);
|
||||
}
|
||||
|
||||
if ($this->isOwnedBy($userId)) {
|
||||
return self::MEMBER_ROLE_OWNER;
|
||||
}
|
||||
@@ -546,10 +579,13 @@ class Collection extends Model
|
||||
|
||||
public function canBeManagedBy(User $user): bool
|
||||
{
|
||||
return in_array($this->activeMemberRoleFor($user), [
|
||||
self::MEMBER_ROLE_OWNER,
|
||||
self::MEMBER_ROLE_EDITOR,
|
||||
], true);
|
||||
if ((int) ($this->group_id ?? 0) > 0) {
|
||||
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
|
||||
|
||||
return $group?->canManageCollections($user) ?? false;
|
||||
}
|
||||
|
||||
return in_array($this->activeMemberRoleFor($user), [self::MEMBER_ROLE_OWNER, self::MEMBER_ROLE_EDITOR], true);
|
||||
}
|
||||
|
||||
public function canManageArtworks(User $user): bool
|
||||
@@ -559,6 +595,12 @@ class Collection extends Model
|
||||
|
||||
public function canManageMembers(User $user): bool
|
||||
{
|
||||
if ((int) ($this->group_id ?? 0) > 0) {
|
||||
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
|
||||
|
||||
return $group?->canManageMembers($user) ?? false;
|
||||
}
|
||||
|
||||
return $this->isCollaborative() && $this->canBeManagedBy($user);
|
||||
}
|
||||
|
||||
|
||||
868
app/Models/Group.php
Normal file
868
app/Models/Group.php
Normal file
@@ -0,0 +1,868 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class Group extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
use Searchable;
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
public const VISIBILITY_PRIVATE = 'private';
|
||||
public const VISIBILITY_UNLISTED = 'unlisted';
|
||||
|
||||
public const LIFECYCLE_ACTIVE = 'active';
|
||||
public const LIFECYCLE_ARCHIVED = 'archived';
|
||||
public const LIFECYCLE_SUSPENDED = 'suspended';
|
||||
|
||||
public const MEMBERSHIP_INVITE_ONLY = 'invite_only';
|
||||
public const MEMBERSHIP_REQUEST_TO_JOIN = 'request_to_join';
|
||||
public const MEMBERSHIP_OPEN = 'open';
|
||||
|
||||
public const ROLE_OWNER = 'owner';
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_EDITOR = 'editor';
|
||||
public const ROLE_MEMBER = 'member';
|
||||
public const ROLE_CONTRIBUTOR = 'contributor';
|
||||
|
||||
public const PERMISSION_REVIEW_JOIN_REQUESTS = 'review_join_requests';
|
||||
public const PERMISSION_REVIEW_SUBMISSIONS = 'review_submissions';
|
||||
public const PERMISSION_MANAGE_RECRUITMENT = 'manage_recruitment';
|
||||
public const PERMISSION_MANAGE_POSTS = 'manage_posts';
|
||||
public const PERMISSION_PUBLISH_POSTS = 'publish_posts';
|
||||
public const PERMISSION_PIN_POSTS = 'pin_posts';
|
||||
public const PERMISSION_MANAGE_MEMBER_PERMISSIONS = 'manage_member_permissions';
|
||||
public const PERMISSION_MANAGE_EVENTS = 'manage_events';
|
||||
public const PERMISSION_MANAGE_CHALLENGES = 'manage_challenges';
|
||||
public const PERMISSION_MANAGE_PROJECTS = 'manage_projects';
|
||||
public const PERMISSION_MANAGE_RELEASES = 'manage_releases';
|
||||
public const PERMISSION_PUBLISH_RELEASES = 'publish_releases';
|
||||
public const PERMISSION_MANAGE_MILESTONES = 'manage_milestones';
|
||||
public const PERMISSION_VIEW_REPUTATION_DASHBOARD = 'view_reputation_dashboard';
|
||||
public const PERMISSION_MANAGE_BADGES = 'manage_badges';
|
||||
public const PERMISSION_VIEW_INTERNAL_TRUST_METRICS = 'view_internal_trust_metrics';
|
||||
public const PERMISSION_FEATURE_RELEASES = 'feature_releases';
|
||||
public const PERMISSION_ASSIGN_RELEASE_LEAD = 'assign_release_lead';
|
||||
public const PERMISSION_MANAGE_ASSETS = 'manage_assets';
|
||||
public const PERMISSION_FEATURE_CHALLENGE_ENTRIES = 'feature_challenge_entries';
|
||||
public const PERMISSION_PUBLISH_EVENT_UPDATES = 'publish_event_updates';
|
||||
public const PERMISSION_ATTACH_ASSETS_TO_PROJECTS = 'attach_assets_to_projects';
|
||||
public const PERMISSION_VIEW_INTERNAL_ASSETS = 'view_internal_assets';
|
||||
public const PERMISSION_MANAGE_ACTIVITY_PINS = 'manage_activity_pins';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_REVOKED = 'revoked';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_user_id',
|
||||
'featured_artwork_id',
|
||||
'is_verified',
|
||||
'founded_at',
|
||||
'name',
|
||||
'slug',
|
||||
'headline',
|
||||
'bio',
|
||||
'type',
|
||||
'visibility',
|
||||
'status',
|
||||
'membership_policy',
|
||||
'website_url',
|
||||
'links_json',
|
||||
'avatar_path',
|
||||
'banner_path',
|
||||
'artworks_count',
|
||||
'collections_count',
|
||||
'followers_count',
|
||||
'last_activity_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'links_json' => 'array',
|
||||
'is_verified' => 'boolean',
|
||||
'artworks_count' => 'integer',
|
||||
'collections_count' => 'integer',
|
||||
'followers_count' => 'integer',
|
||||
'founded_at' => 'datetime',
|
||||
'last_activity_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupMember::class);
|
||||
}
|
||||
|
||||
public function invitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupInvitation::class);
|
||||
}
|
||||
|
||||
public function follows(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupFollow::class);
|
||||
}
|
||||
|
||||
public function artworks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Artwork::class);
|
||||
}
|
||||
|
||||
public function collections(): HasMany
|
||||
{
|
||||
return $this->hasMany(Collection::class);
|
||||
}
|
||||
|
||||
public function joinRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupJoinRequest::class);
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupPost::class);
|
||||
}
|
||||
|
||||
public function recruitmentProfile(): HasOne
|
||||
{
|
||||
return $this->hasOne(GroupRecruitmentProfile::class);
|
||||
}
|
||||
|
||||
public function projects(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupProject::class);
|
||||
}
|
||||
|
||||
public function releases(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupRelease::class);
|
||||
}
|
||||
|
||||
public function challenges(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupChallenge::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupEvent::class);
|
||||
}
|
||||
|
||||
public function assets(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupAsset::class);
|
||||
}
|
||||
|
||||
public function activityItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupActivityItem::class);
|
||||
}
|
||||
|
||||
public function contributorStats(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupContributorStat::class);
|
||||
}
|
||||
|
||||
public function badges(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupBadge::class);
|
||||
}
|
||||
|
||||
public function memberBadges(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupMemberBadge::class);
|
||||
}
|
||||
|
||||
public function discoveryMetric(): HasOne
|
||||
{
|
||||
return $this->hasOne(GroupDiscoveryMetric::class);
|
||||
}
|
||||
|
||||
public function historyEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupHistory::class);
|
||||
}
|
||||
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('visibility', self::VISIBILITY_PUBLIC)
|
||||
->where('status', self::LIFECYCLE_ACTIVE);
|
||||
}
|
||||
|
||||
public static function acceptedVisibilityValues(): array
|
||||
{
|
||||
return [self::VISIBILITY_PUBLIC, self::VISIBILITY_PRIVATE, self::VISIBILITY_UNLISTED];
|
||||
}
|
||||
|
||||
public static function acceptedMembershipPolicies(): array
|
||||
{
|
||||
return [self::MEMBERSHIP_INVITE_ONLY, self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN];
|
||||
}
|
||||
|
||||
public static function normalizeMemberRole(string $role): string
|
||||
{
|
||||
$normalized = strtolower(trim($role));
|
||||
|
||||
return $normalized === self::ROLE_CONTRIBUTOR ? self::ROLE_MEMBER : $normalized;
|
||||
}
|
||||
|
||||
public static function displayRole(?string $role): ?string
|
||||
{
|
||||
if ($role === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::normalizeMemberRole($role) === self::ROLE_MEMBER ? self::ROLE_CONTRIBUTOR : self::normalizeMemberRole($role);
|
||||
}
|
||||
|
||||
public function isOwnedBy(User|int|null $user): bool
|
||||
{
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
return $userId !== null && (int) $userId === (int) $this->owner_user_id;
|
||||
}
|
||||
|
||||
public function isPubliclyVisible(): bool
|
||||
{
|
||||
return in_array($this->visibility, [self::VISIBILITY_PUBLIC, self::VISIBILITY_UNLISTED], true)
|
||||
&& $this->status !== self::LIFECYCLE_SUSPENDED;
|
||||
}
|
||||
|
||||
public function isOperational(): bool
|
||||
{
|
||||
return $this->status === self::LIFECYCLE_ACTIVE;
|
||||
}
|
||||
|
||||
public function canArchive(User $user): bool
|
||||
{
|
||||
return $this->isOwnedBy($user);
|
||||
}
|
||||
|
||||
public function canViewStudio(User $user): bool
|
||||
{
|
||||
if ($this->status === self::LIFECYCLE_SUSPENDED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasActiveMember($user);
|
||||
}
|
||||
|
||||
public function activeRoleFor(User|int|null $user): ?string
|
||||
{
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
if ($userId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isOwnedBy($userId)) {
|
||||
return self::ROLE_OWNER;
|
||||
}
|
||||
|
||||
$members = $this->relationLoaded('members')
|
||||
? $this->members
|
||||
: $this->members()->where('status', self::STATUS_ACTIVE)->get();
|
||||
|
||||
return $members
|
||||
->first(fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE)
|
||||
?->role;
|
||||
}
|
||||
|
||||
public function hasActiveMember(User|int|null $user): bool
|
||||
{
|
||||
return $this->activeRoleFor($user) !== null;
|
||||
}
|
||||
|
||||
public function activeMembershipFor(User|int|null $user): ?GroupMember
|
||||
{
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
if ($userId === null || $this->isOwnedBy($userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$members = $this->relationLoaded('members')
|
||||
? $this->members
|
||||
: $this->members()->where('status', self::STATUS_ACTIVE)->get();
|
||||
|
||||
return $members->first(
|
||||
fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE
|
||||
);
|
||||
}
|
||||
|
||||
public function permissionOverridesFor(User|int|null $user): array
|
||||
{
|
||||
if ($this->isOwnedBy($user)) {
|
||||
return collect(self::allowedPermissionOverrides())
|
||||
->mapWithKeys(fn (string $permission): array => [$permission => true])
|
||||
->all();
|
||||
}
|
||||
|
||||
$member = $this->activeMembershipFor($user);
|
||||
if (! $member) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($member->permission_overrides_json ?? [])
|
||||
->mapWithKeys(function ($override): array {
|
||||
if (is_array($override)) {
|
||||
$key = trim((string) ($override['key'] ?? ''));
|
||||
if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$key => (bool) ($override['is_allowed'] ?? false)];
|
||||
}
|
||||
|
||||
$key = trim((string) $override);
|
||||
if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$key => true];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function hasPermission(User|int|null $user, string $permission): bool
|
||||
{
|
||||
return $this->permissionOverridesFor($user)[$permission] ?? false;
|
||||
}
|
||||
|
||||
public function hasDeniedPermission(User|int|null $user, string $permission): bool
|
||||
{
|
||||
$overrides = $this->permissionOverridesFor($user);
|
||||
|
||||
return array_key_exists($permission, $overrides) && $overrides[$permission] === false;
|
||||
}
|
||||
|
||||
public static function permissionKeys(): array
|
||||
{
|
||||
return self::allowedPermissionOverrides();
|
||||
}
|
||||
|
||||
public function canBeViewedBy(?User $user): bool
|
||||
{
|
||||
if ($this->isPubliclyVisible()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user !== null && $this->hasActiveMember($user);
|
||||
}
|
||||
|
||||
public function canManage(User $user): bool
|
||||
{
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true);
|
||||
}
|
||||
|
||||
public function canManageMembers(User $user): bool
|
||||
{
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true);
|
||||
}
|
||||
|
||||
public function canPublishArtworks(User $user): bool
|
||||
{
|
||||
return $this->isOperational()
|
||||
&& in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true);
|
||||
}
|
||||
|
||||
public function canCreateArtworkDrafts(User $user): bool
|
||||
{
|
||||
return $this->isOperational() && $this->hasActiveMember($user);
|
||||
}
|
||||
|
||||
public function canSubmitArtworkForReview(User $user): bool
|
||||
{
|
||||
return $this->isOperational() && $this->hasActiveMember($user);
|
||||
}
|
||||
|
||||
public function canReviewSubmissions(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $this->activeRoleFor($user);
|
||||
|
||||
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS);
|
||||
}
|
||||
|
||||
public function canRequestJoin(?User $user): bool
|
||||
{
|
||||
if (! $this->isOperational() || $user === null || $this->hasActiveMember($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->membership_policy, [self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN], true);
|
||||
}
|
||||
|
||||
public function canReviewJoinRequests(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $this->activeRoleFor($user);
|
||||
|
||||
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS);
|
||||
}
|
||||
|
||||
public function canManageRecruitment(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RECRUITMENT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $this->activeRoleFor($user);
|
||||
|
||||
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_RECRUITMENT);
|
||||
}
|
||||
|
||||
public function canManagePosts(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_POSTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $this->activeRoleFor($user);
|
||||
|
||||
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_POSTS);
|
||||
}
|
||||
|
||||
public function canPublishPosts(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_POSTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $this->activeRoleFor($user);
|
||||
|
||||
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_POSTS)
|
||||
|| $this->canManagePosts($user);
|
||||
}
|
||||
|
||||
public function canPinPosts(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_PIN_POSTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_PIN_POSTS);
|
||||
}
|
||||
|
||||
public function canManageMemberPermissions(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS);
|
||||
}
|
||||
|
||||
public function canManageEvents(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_EVENTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_EVENTS);
|
||||
}
|
||||
|
||||
public function canManageChallenges(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_CHALLENGES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_CHALLENGES);
|
||||
}
|
||||
|
||||
public function canManageProjects(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_PROJECTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_PROJECTS);
|
||||
}
|
||||
|
||||
public function canManageReleases(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RELEASES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canManageProjects($user)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_RELEASES);
|
||||
}
|
||||
|
||||
public function canPublishReleases(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_RELEASES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_RELEASES)
|
||||
|| $this->canManageReleases($user);
|
||||
}
|
||||
|
||||
public function canManageMilestones(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MILESTONES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canManageProjects($user)
|
||||
|| $this->canManageReleases($user)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_MILESTONES);
|
||||
}
|
||||
|
||||
public function canViewReputationDashboard(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD);
|
||||
}
|
||||
|
||||
public function canManageBadges(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_BADGES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_BADGES);
|
||||
}
|
||||
|
||||
public function canViewInternalTrustMetrics(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS);
|
||||
}
|
||||
|
||||
public function canFeatureReleases(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_RELEASES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_FEATURE_RELEASES);
|
||||
}
|
||||
|
||||
public function canAssignReleaseLead(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canManageReleases($user)
|
||||
|| $this->hasPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD);
|
||||
}
|
||||
|
||||
public function canManageAssets(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ASSETS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_ASSETS);
|
||||
}
|
||||
|
||||
public function canFeatureChallengeEntries(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES);
|
||||
}
|
||||
|
||||
public function canPublishEventUpdates(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canManageEvents($user)
|
||||
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES);
|
||||
}
|
||||
|
||||
public function canAttachAssetsToProjects(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canManageProjects($user)
|
||||
|| $this->hasPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS);
|
||||
}
|
||||
|
||||
public function canViewInternalAssets(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS);
|
||||
}
|
||||
|
||||
public function canPinActivity(User $user): bool
|
||||
{
|
||||
if (! $this->isOperational()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|
||||
|| $this->hasPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS);
|
||||
}
|
||||
|
||||
public static function allowedPermissionOverrides(): array
|
||||
{
|
||||
return [
|
||||
self::PERMISSION_REVIEW_JOIN_REQUESTS,
|
||||
self::PERMISSION_REVIEW_SUBMISSIONS,
|
||||
self::PERMISSION_MANAGE_RECRUITMENT,
|
||||
self::PERMISSION_MANAGE_POSTS,
|
||||
self::PERMISSION_PUBLISH_POSTS,
|
||||
self::PERMISSION_PIN_POSTS,
|
||||
self::PERMISSION_MANAGE_MEMBER_PERMISSIONS,
|
||||
self::PERMISSION_MANAGE_EVENTS,
|
||||
self::PERMISSION_MANAGE_CHALLENGES,
|
||||
self::PERMISSION_MANAGE_PROJECTS,
|
||||
self::PERMISSION_MANAGE_RELEASES,
|
||||
self::PERMISSION_PUBLISH_RELEASES,
|
||||
self::PERMISSION_MANAGE_MILESTONES,
|
||||
self::PERMISSION_VIEW_REPUTATION_DASHBOARD,
|
||||
self::PERMISSION_MANAGE_BADGES,
|
||||
self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS,
|
||||
self::PERMISSION_FEATURE_RELEASES,
|
||||
self::PERMISSION_ASSIGN_RELEASE_LEAD,
|
||||
self::PERMISSION_MANAGE_ASSETS,
|
||||
self::PERMISSION_FEATURE_CHALLENGE_ENTRIES,
|
||||
self::PERMISSION_PUBLISH_EVENT_UPDATES,
|
||||
self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS,
|
||||
self::PERMISSION_VIEW_INTERNAL_ASSETS,
|
||||
self::PERMISSION_MANAGE_ACTIVITY_PINS,
|
||||
];
|
||||
}
|
||||
|
||||
public function canManageCollections(User $user): bool
|
||||
{
|
||||
return $this->isOperational() && $this->canPublishArtworks($user);
|
||||
}
|
||||
|
||||
public function avatarUrl(): ?string
|
||||
{
|
||||
return $this->assetUrl($this->avatar_path);
|
||||
}
|
||||
|
||||
public function bannerUrl(): ?string
|
||||
{
|
||||
return $this->assetUrl($this->banner_path);
|
||||
}
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return route('groups.show', ['group' => $this->slug]);
|
||||
}
|
||||
|
||||
public function shouldBeSearchable(): bool
|
||||
{
|
||||
return $this->visibility === self::VISIBILITY_PUBLIC && $this->status === self::LIFECYCLE_ACTIVE;
|
||||
}
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
$recruitment = $this->relationLoaded('recruitmentProfile')
|
||||
? $this->recruitmentProfile
|
||||
: $this->recruitmentProfile()->first();
|
||||
|
||||
$memberNames = $this->members()
|
||||
->with('user:id,name,username')
|
||||
->where('status', self::STATUS_ACTIVE)
|
||||
->limit(12)
|
||||
->get()
|
||||
->map(fn (GroupMember $member): string => (string) ($member->user?->name ?: $member->user?->username ?: ''))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'id' => (int) $this->id,
|
||||
'name' => (string) $this->name,
|
||||
'slug' => (string) $this->slug,
|
||||
'headline' => (string) ($this->headline ?? ''),
|
||||
'bio' => (string) ($this->bio ?? ''),
|
||||
'type' => (string) ($this->type ?? ''),
|
||||
'visibility' => (string) $this->visibility,
|
||||
'status' => (string) ($this->status ?? self::LIFECYCLE_ACTIVE),
|
||||
'artworks_count' => (int) ($this->artworks_count ?? 0),
|
||||
'followers_count' => (int) ($this->followers_count ?? 0),
|
||||
'is_recruiting' => (bool) ($recruitment?->is_recruiting ?? false),
|
||||
'recruitment_headline' => (string) ($recruitment?->headline ?? ''),
|
||||
'recruitment_roles' => array_values(array_filter($recruitment?->roles_json ?? [])),
|
||||
'recruitment_skills' => array_values(array_filter($recruitment?->skills_json ?? [])),
|
||||
'release_titles' => $this->releases()->where('visibility', GroupRelease::VISIBILITY_PUBLIC)->latest('published_at')->limit(6)->pluck('title')->filter()->values()->all(),
|
||||
'project_titles' => $this->projects()->where('visibility', GroupProject::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(),
|
||||
'challenge_titles' => $this->challenges()->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(),
|
||||
'event_titles' => $this->events()->where('visibility', GroupEvent::VISIBILITY_PUBLIC)->latest('start_at')->limit(6)->pluck('title')->filter()->values()->all(),
|
||||
'badge_keys' => $this->badges()->latest('awarded_at')->limit(6)->pluck('badge_key')->filter()->values()->all(),
|
||||
'member_names' => $memberNames,
|
||||
];
|
||||
}
|
||||
|
||||
private function assetUrl(?string $path): ?string
|
||||
{
|
||||
$trimmed = trim((string) $path);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($trimmed, '/');
|
||||
}
|
||||
}
|
||||
45
app/Models/GroupActivityItem.php
Normal file
45
app/Models/GroupActivityItem.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupActivityItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
public const VISIBILITY_INTERNAL = 'internal';
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'type',
|
||||
'visibility',
|
||||
'actor_user_id',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'headline',
|
||||
'summary',
|
||||
'is_pinned',
|
||||
'occurred_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_pinned' => 'boolean',
|
||||
'occurred_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_user_id');
|
||||
}
|
||||
}
|
||||
87
app/Models/GroupAsset.php
Normal file
87
app/Models/GroupAsset.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class GroupAsset extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const CATEGORY_LOGO = 'logo';
|
||||
public const CATEGORY_BRAND = 'brand';
|
||||
public const CATEGORY_PALETTE = 'palette';
|
||||
public const CATEGORY_WATERMARK = 'watermark';
|
||||
public const CATEGORY_TEMPLATE = 'template';
|
||||
public const CATEGORY_REFERENCE = 'reference';
|
||||
public const CATEGORY_SOURCE_PACK = 'source_pack';
|
||||
public const CATEGORY_PROMO = 'promo';
|
||||
public const CATEGORY_MISC = 'misc';
|
||||
|
||||
public const VISIBILITY_INTERNAL = 'internal';
|
||||
public const VISIBILITY_MEMBERS_ONLY = 'members_only';
|
||||
public const VISIBILITY_PUBLIC_DOWNLOAD = 'public_download';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'title',
|
||||
'description',
|
||||
'category',
|
||||
'file_path',
|
||||
'preview_path',
|
||||
'visibility',
|
||||
'status',
|
||||
'linked_project_id',
|
||||
'uploaded_by_user_id',
|
||||
'approved_by_user_id',
|
||||
'is_featured',
|
||||
'file_meta_json',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
'file_meta_json' => 'array',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function linkedProject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GroupProject::class, 'linked_project_id');
|
||||
}
|
||||
|
||||
public function uploader(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by_user_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_user_id');
|
||||
}
|
||||
|
||||
public function canBeViewedBy(?User $viewer): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($this->visibility) {
|
||||
self::VISIBILITY_PUBLIC_DOWNLOAD => $this->group->canBeViewedBy($viewer),
|
||||
self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer),
|
||||
default => $viewer !== null && $this->group->canViewInternalAssets($viewer),
|
||||
};
|
||||
}
|
||||
}
|
||||
31
app/Models/GroupBadge.php
Normal file
31
app/Models/GroupBadge.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupBadge extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'badge_key',
|
||||
'awarded_at',
|
||||
'meta_json',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'awarded_at' => 'datetime',
|
||||
'meta_json' => 'array',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
}
|
||||
125
app/Models/GroupChallenge.php
Normal file
125
app/Models/GroupChallenge.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class GroupChallenge extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
public const VISIBILITY_UNLISTED = 'unlisted';
|
||||
public const VISIBILITY_PRIVATE = 'private';
|
||||
|
||||
public const PARTICIPATION_GROUP_ONLY = 'group_only';
|
||||
public const PARTICIPATION_INVITE_ONLY = 'invite_only';
|
||||
public const PARTICIPATION_PUBLIC = 'public';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_ENDED = 'ended';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'title',
|
||||
'slug',
|
||||
'summary',
|
||||
'description',
|
||||
'cover_path',
|
||||
'visibility',
|
||||
'participation_scope',
|
||||
'status',
|
||||
'start_at',
|
||||
'end_at',
|
||||
'rules_text',
|
||||
'submission_instructions',
|
||||
'judging_mode',
|
||||
'linked_collection_id',
|
||||
'linked_project_id',
|
||||
'created_by_user_id',
|
||||
'featured_artwork_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function linkedCollection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Collection::class, 'linked_collection_id');
|
||||
}
|
||||
|
||||
public function linkedProject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GroupProject::class, 'linked_project_id');
|
||||
}
|
||||
|
||||
public function featuredArtwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'featured_artwork_id');
|
||||
}
|
||||
|
||||
public function artworkLinks(): HasMany
|
||||
{
|
||||
return $this->hasMany(GroupChallengeArtwork::class);
|
||||
}
|
||||
|
||||
public function artworks(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Artwork::class, 'group_challenge_artworks')
|
||||
->withPivot(['submitted_by_user_id', 'sort_order'])
|
||||
->withTimestamps()
|
||||
->orderBy('group_challenge_artworks.sort_order');
|
||||
}
|
||||
|
||||
public function canBeViewedBy(?User $viewer): bool
|
||||
{
|
||||
if ($this->visibility !== self::VISIBILITY_PRIVATE) {
|
||||
return $this->group->canBeViewedBy($viewer);
|
||||
}
|
||||
|
||||
return $viewer !== null && $this->group->canViewStudio($viewer);
|
||||
}
|
||||
|
||||
public function coverUrl(): ?string
|
||||
{
|
||||
$path = trim((string) $this->cover_path);
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
36
app/Models/GroupChallengeArtwork.php
Normal file
36
app/Models/GroupChallengeArtwork.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupChallengeArtwork extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group_challenge_id',
|
||||
'artwork_id',
|
||||
'submitted_by_user_id',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
public function challenge(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GroupChallenge::class, 'group_challenge_id');
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function submitter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_user_id');
|
||||
}
|
||||
}
|
||||
44
app/Models/GroupContributorStat.php
Normal file
44
app/Models/GroupContributorStat.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupContributorStat extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'user_id',
|
||||
'credited_artworks_count',
|
||||
'release_count',
|
||||
'project_count',
|
||||
'review_actions_count',
|
||||
'approved_submissions_count',
|
||||
'reputation_meta_json',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'credited_artworks_count' => 'integer',
|
||||
'release_count' => 'integer',
|
||||
'project_count' => 'integer',
|
||||
'review_actions_count' => 'integer',
|
||||
'approved_submissions_count' => 'integer',
|
||||
'reputation_meta_json' => 'array',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
38
app/Models/GroupDiscoveryMetric.php
Normal file
38
app/Models/GroupDiscoveryMetric.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupDiscoveryMetric extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'freshness_score',
|
||||
'activity_score',
|
||||
'release_score',
|
||||
'trust_score',
|
||||
'collaboration_score',
|
||||
'last_calculated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'freshness_score' => 'float',
|
||||
'activity_score' => 'float',
|
||||
'release_score' => 'float',
|
||||
'trust_score' => 'float',
|
||||
'collaboration_score' => 'float',
|
||||
'last_calculated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
}
|
||||
118
app/Models/GroupEvent.php
Normal file
118
app/Models/GroupEvent.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class GroupEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const TYPE_LAUNCH = 'launch';
|
||||
public const TYPE_CHALLENGE = 'challenge';
|
||||
public const TYPE_LIVESTREAM = 'livestream';
|
||||
public const TYPE_MEETUP = 'meetup';
|
||||
public const TYPE_MILESTONE = 'milestone';
|
||||
public const TYPE_SHOWCASE = 'showcase';
|
||||
public const TYPE_INTERNAL_SESSION = 'internal_session';
|
||||
public const TYPE_RELEASE_WINDOW = 'release_window';
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
public const VISIBILITY_MEMBERS_ONLY = 'members_only';
|
||||
public const VISIBILITY_PRIVATE = 'private';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'title',
|
||||
'slug',
|
||||
'summary',
|
||||
'description',
|
||||
'event_type',
|
||||
'visibility',
|
||||
'start_at',
|
||||
'end_at',
|
||||
'timezone',
|
||||
'cover_path',
|
||||
'location',
|
||||
'external_url',
|
||||
'linked_project_id',
|
||||
'linked_collection_id',
|
||||
'linked_challenge_id',
|
||||
'status',
|
||||
'is_featured',
|
||||
'created_by_user_id',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
'is_featured' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function linkedProject(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GroupProject::class, 'linked_project_id');
|
||||
}
|
||||
|
||||
public function linkedCollection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Collection::class, 'linked_collection_id');
|
||||
}
|
||||
|
||||
public function linkedChallenge(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GroupChallenge::class, 'linked_challenge_id');
|
||||
}
|
||||
|
||||
public function canBeViewedBy(?User $viewer): bool
|
||||
{
|
||||
return match ($this->visibility) {
|
||||
self::VISIBILITY_PUBLIC => $this->group->canBeViewedBy($viewer),
|
||||
self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer),
|
||||
default => $viewer !== null && $this->group->canViewStudio($viewer),
|
||||
};
|
||||
}
|
||||
|
||||
public function coverUrl(): ?string
|
||||
{
|
||||
$path = trim((string) $this->cover_path);
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
29
app/Models/GroupFollow.php
Normal file
29
app/Models/GroupFollow.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupFollow extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
44
app/Models/GroupHistory.php
Normal file
44
app/Models/GroupHistory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'actor_user_id',
|
||||
'action_type',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'summary',
|
||||
'before_json',
|
||||
'after_json',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'before_json' => 'array',
|
||||
'after_json' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_user_id');
|
||||
}
|
||||
}
|
||||
69
app/Models/GroupInvitation.php
Normal file
69
app/Models/GroupInvitation.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupInvitation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_ACCEPTED = 'accepted';
|
||||
public const STATUS_DECLINED = 'declined';
|
||||
public const STATUS_REVOKED = 'revoked';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $fillable = [
|
||||
'group_id',
|
||||
'invited_user_id',
|
||||
'invited_by_user_id',
|
||||
'source_group_member_id',
|
||||
'role',
|
||||
'status',
|
||||
'token',
|
||||
'note',
|
||||
'invited_at',
|
||||
'expires_at',
|
||||
'responded_at',
|
||||
'accepted_at',
|
||||
'revoked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'invited_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'responded_at' => 'datetime',
|
||||
'accepted_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'token';
|
||||
}
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
public function invitedUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_user_id');
|
||||
}
|
||||
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_user_id');
|
||||
}
|
||||
|
||||
public function sourceGroupMember(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GroupMember::class, 'source_group_member_id');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user