Fixes
This commit is contained in:
@@ -8,10 +8,17 @@ use App\Enums\UserRole;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\AuthAuditLog;
|
use App\Models\AuthAuditLog;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Report;
|
||||||
use App\Models\Story;
|
use App\Models\Story;
|
||||||
|
use App\Models\Upload;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Moderation\ReportTargetResolver;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -33,6 +40,252 @@ final class AdminController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function dailyActivity(Request $request, ReportTargetResolver $reportTargets): Response
|
||||||
|
{
|
||||||
|
$selectedDate = $this->resolveActivityDate($request);
|
||||||
|
$periodStart = $selectedDate->copy()->startOfDay();
|
||||||
|
$periodEnd = $selectedDate->copy()->endOfDay();
|
||||||
|
|
||||||
|
$users = User::query()
|
||||||
|
->select('id', 'name', 'username', 'email', 'role', 'created_at')
|
||||||
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(25)
|
||||||
|
->get()
|
||||||
|
->map(fn (User $user): array => [
|
||||||
|
'id' => (int) $user->id,
|
||||||
|
'name' => (string) $user->name,
|
||||||
|
'username' => $user->username,
|
||||||
|
'email' => (string) $user->email,
|
||||||
|
'role' => (string) $user->role,
|
||||||
|
'created_at' => optional($user->created_at)?->toISOString(),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$artworks = Artwork::query()
|
||||||
|
->with('user:id,name,username')
|
||||||
|
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
|
||||||
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(25)
|
||||||
|
->get()
|
||||||
|
->map(fn (Artwork $artwork): array => [
|
||||||
|
'id' => (int) $artwork->id,
|
||||||
|
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
|
||||||
|
'status' => (string) ($artwork->artwork_status ?? 'unknown'),
|
||||||
|
'thumb' => $artwork->thumbUrl('sm') ?? null,
|
||||||
|
'created_at' => optional($artwork->created_at)?->toISOString(),
|
||||||
|
'user' => $artwork->user ? [
|
||||||
|
'id' => (int) $artwork->user->id,
|
||||||
|
'name' => (string) $artwork->user->name,
|
||||||
|
'username' => $artwork->user->username,
|
||||||
|
] : null,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$stories = Story::query()
|
||||||
|
->with('creator:id,name,username')
|
||||||
|
->select('id', 'title', 'status', 'created_at', 'published_at', 'creator_id')
|
||||||
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(25)
|
||||||
|
->get()
|
||||||
|
->map(fn (Story $story): array => [
|
||||||
|
'id' => (int) $story->id,
|
||||||
|
'title' => (string) ($story->title ?? 'Untitled story'),
|
||||||
|
'status' => (string) ($story->status ?? 'draft'),
|
||||||
|
'created_at' => optional($story->created_at)?->toISOString(),
|
||||||
|
'published_at' => optional($story->published_at)?->toISOString(),
|
||||||
|
'creator' => $story->creator ? [
|
||||||
|
'id' => (int) $story->creator->id,
|
||||||
|
'name' => (string) $story->creator->name,
|
||||||
|
'username' => $story->creator->username,
|
||||||
|
] : null,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$uploads = Schema::hasTable('uploads')
|
||||||
|
? Upload::query()
|
||||||
|
->select('id', 'user_id', 'type', 'status', 'processing_state', 'title', 'created_at', 'moderation_status', 'moderated_at', 'moderated_by', 'moderation_note')
|
||||||
|
->where(function ($query) use ($periodStart, $periodEnd): void {
|
||||||
|
$query->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||||
|
->orWhereBetween('moderated_at', [$periodStart, $periodEnd]);
|
||||||
|
})
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(40)
|
||||||
|
->get()
|
||||||
|
->map(fn (Upload $upload): array => [
|
||||||
|
'id' => (string) $upload->id,
|
||||||
|
'user_id' => $upload->user_id !== null ? (int) $upload->user_id : null,
|
||||||
|
'title' => (string) ($upload->title ?? 'Untitled upload'),
|
||||||
|
'type' => (string) ($upload->type ?? 'unknown'),
|
||||||
|
'status' => (string) ($upload->status ?? 'unknown'),
|
||||||
|
'processing_state' => (string) ($upload->processing_state ?? 'unknown'),
|
||||||
|
'moderation_status' => (string) ($upload->moderation_status ?? 'unknown'),
|
||||||
|
'created_at' => optional($upload->created_at)?->toISOString(),
|
||||||
|
'moderated_at' => optional($upload->moderated_at)?->toISOString(),
|
||||||
|
'moderated_by' => $upload->moderated_by !== null ? (int) $upload->moderated_by : null,
|
||||||
|
'moderation_note' => $upload->moderation_note,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$reports = Schema::hasTable('reports')
|
||||||
|
? Report::query()
|
||||||
|
->with(['reporter:id,username', 'lastModeratedBy:id,username'])
|
||||||
|
->where(function ($query) use ($periodStart, $periodEnd): void {
|
||||||
|
$query->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||||
|
->orWhereBetween('last_moderated_at', [$periodStart, $periodEnd]);
|
||||||
|
})
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(30)
|
||||||
|
->get()
|
||||||
|
->map(fn (Report $report): array => [
|
||||||
|
'id' => (int) $report->id,
|
||||||
|
'status' => (string) $report->status,
|
||||||
|
'reason' => (string) $report->reason,
|
||||||
|
'target_type' => (string) $report->target_type,
|
||||||
|
'target_id' => (int) $report->target_id,
|
||||||
|
'created_at' => optional($report->created_at)?->toISOString(),
|
||||||
|
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
|
||||||
|
'moderator_note' => $report->moderator_note,
|
||||||
|
'reporter' => $report->reporter ? [
|
||||||
|
'id' => (int) $report->reporter->id,
|
||||||
|
'username' => (string) $report->reporter->username,
|
||||||
|
] : null,
|
||||||
|
'last_moderated_by' => $report->lastModeratedBy ? [
|
||||||
|
'id' => (int) $report->lastModeratedBy->id,
|
||||||
|
'username' => (string) $report->lastModeratedBy->username,
|
||||||
|
] : null,
|
||||||
|
'target' => $reportTargets->summarize($report),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$usernameRequests = Schema::hasTable('username_approval_requests')
|
||||||
|
? (function () use ($periodStart, $periodEnd) {
|
||||||
|
$requestColumns = Schema::getColumnListing('username_approval_requests');
|
||||||
|
$selects = [
|
||||||
|
'requests.id',
|
||||||
|
'requests.user_id',
|
||||||
|
'requests.requested_username',
|
||||||
|
'requests.status',
|
||||||
|
'requests.created_at',
|
||||||
|
'users.username as current_username',
|
||||||
|
'users.name as current_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array('context', $requestColumns, true)) {
|
||||||
|
$selects[] = 'requests.context';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('similar_to', $requestColumns, true)) {
|
||||||
|
$selects[] = 'requests.similar_to';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('review_note', $requestColumns, true)) {
|
||||||
|
$selects[] = 'requests.review_note';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('reviewed_at', $requestColumns, true)) {
|
||||||
|
$selects[] = 'requests.reviewed_at';
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = DB::table('username_approval_requests as requests')
|
||||||
|
->leftJoin('users', 'users.id', '=', 'requests.user_id')
|
||||||
|
->select($selects)
|
||||||
|
->where(function (Builder $query) use ($periodStart, $periodEnd, $requestColumns): void {
|
||||||
|
$query->whereBetween('requests.created_at', [$periodStart, $periodEnd]);
|
||||||
|
|
||||||
|
if (in_array('reviewed_at', $requestColumns, true)) {
|
||||||
|
$query->orWhereBetween('requests.reviewed_at', [$periodStart, $periodEnd]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderByDesc('requests.created_at')
|
||||||
|
->limit(30);
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->get()
|
||||||
|
->map(fn ($row): array => [
|
||||||
|
'id' => (int) $row->id,
|
||||||
|
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
|
||||||
|
'requested_username' => (string) $row->requested_username,
|
||||||
|
'status' => (string) ($row->status ?? 'pending'),
|
||||||
|
'context' => $row->context ?? null,
|
||||||
|
'similar_to' => $row->similar_to ?? null,
|
||||||
|
'reason' => $row->review_note ?? null,
|
||||||
|
'created_at' => $this->serializeDatabaseTimestamp($row->created_at),
|
||||||
|
'reviewed_at' => $this->serializeDatabaseTimestamp($row->reviewed_at ?? null),
|
||||||
|
'current_username' => $row->current_username,
|
||||||
|
'current_name' => $row->current_name,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
})()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$authEvents = Schema::hasTable('auth_audit_logs')
|
||||||
|
? AuthAuditLog::query()
|
||||||
|
->with('user:id,name,username,email,role')
|
||||||
|
->select('id', 'user_id', 'event_type', 'identifier', 'status', 'reason', 'ip', 'created_at')
|
||||||
|
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(30)
|
||||||
|
->get()
|
||||||
|
->map(fn (AuthAuditLog $log): array => [
|
||||||
|
'id' => (int) $log->id,
|
||||||
|
'event_type' => (string) $log->event_type,
|
||||||
|
'identifier' => $log->identifier,
|
||||||
|
'status' => (string) $log->status,
|
||||||
|
'reason' => $log->reason,
|
||||||
|
'ip' => $log->ip,
|
||||||
|
'created_at' => optional($log->created_at)?->toISOString(),
|
||||||
|
'user' => $log->user ? [
|
||||||
|
'id' => (int) $log->user->id,
|
||||||
|
'name' => (string) $log->user->name,
|
||||||
|
'username' => $log->user->username,
|
||||||
|
'email' => (string) $log->user->email,
|
||||||
|
'role' => (string) $log->user->role,
|
||||||
|
] : null,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/DailyActivity', [
|
||||||
|
'selectedDate' => $selectedDate->toDateString(),
|
||||||
|
'summary' => [
|
||||||
|
'new_users' => $users->count(),
|
||||||
|
'new_artworks' => $artworks->count(),
|
||||||
|
'new_stories' => $stories->count(),
|
||||||
|
'upload_events' => $uploads->count(),
|
||||||
|
'report_events' => $reports->count(),
|
||||||
|
'username_events' => $usernameRequests->count(),
|
||||||
|
'auth_events' => $authEvents->count(),
|
||||||
|
'moderated_uploads' => $uploads->filter(fn (array $upload): bool => ! empty($upload['moderated_at']))->count(),
|
||||||
|
'moderated_reports' => $reports->filter(fn (array $report): bool => ! empty($report['last_moderated_at']))->count(),
|
||||||
|
],
|
||||||
|
'queues' => [
|
||||||
|
'pending_uploads' => Schema::hasTable('uploads')
|
||||||
|
? Upload::query()->where('status', 'draft')->where('moderation_status', 'pending')->count()
|
||||||
|
: 0,
|
||||||
|
'open_reports' => Schema::hasTable('reports')
|
||||||
|
? Report::query()->where('status', 'open')->count()
|
||||||
|
: 0,
|
||||||
|
'pending_username_requests' => Schema::hasTable('username_approval_requests')
|
||||||
|
? DB::table('username_approval_requests')->where('status', 'pending')->count()
|
||||||
|
: 0,
|
||||||
|
],
|
||||||
|
'sections' => [
|
||||||
|
'users' => $users,
|
||||||
|
'artworks' => $artworks,
|
||||||
|
'stories' => $stories,
|
||||||
|
'uploads' => $uploads,
|
||||||
|
'reports' => $reports,
|
||||||
|
'username_requests' => $usernameRequests,
|
||||||
|
'auth_events' => $authEvents,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function users(Request $request): Response
|
public function users(Request $request): Response
|
||||||
@@ -237,4 +490,36 @@ final class AdminController extends Controller
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveActivityDate(Request $request): Carbon
|
||||||
|
{
|
||||||
|
$date = $request->string('date')->trim()->toString();
|
||||||
|
|
||||||
|
if ($date === '') {
|
||||||
|
return today();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::createFromFormat('Y-m-d', $date)->startOfDay();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return today();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeDatabaseTimestamp(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof Carbon) {
|
||||||
|
return $value->toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse((string) $value)->toISOString();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -751,24 +751,42 @@ class StoryController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$disk = Storage::disk('public');
|
try {
|
||||||
$base = 'stories/' . now()->format('Y/m') . '/' . Str::uuid();
|
$this->assertStoryMediaStorageIsAllowed();
|
||||||
$extension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
|
} catch (\RuntimeException $e) {
|
||||||
$originalPath = $base . '/original.' . $extension;
|
return response()->json([
|
||||||
$thumbnailPath = $base . '/thumbnail.webp';
|
'message' => $e->getMessage(),
|
||||||
$mediumPath = $base . '/medium.webp';
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$stream = fopen($sourcePath, 'rb');
|
$raw = file_get_contents($sourcePath);
|
||||||
if ($stream === false) {
|
if ($raw === false || $raw === '') {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Unable to process uploaded image. Please try again.',
|
'message' => 'Unable to process uploaded image. Please try again.',
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$hash = hash('sha256', $raw);
|
||||||
$disk->put($originalPath, $stream);
|
$originalExtension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
|
||||||
} finally {
|
if ($originalExtension === 'jpeg') {
|
||||||
fclose($stream);
|
$originalExtension = 'jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalPath = $this->storyMediaPath('original', $hash, $hash . '.' . $originalExtension);
|
||||||
|
$thumbnailPath = $this->storyMediaPath('sm', $hash);
|
||||||
|
$mediumPath = $this->storyMediaPath('md', $hash);
|
||||||
|
$disk = Storage::disk($this->storyMediaDiskName());
|
||||||
|
|
||||||
|
$written = $disk->put($originalPath, $raw, [
|
||||||
|
'visibility' => 'public',
|
||||||
|
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||||
|
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($written !== true) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Unable to store uploaded image. Please try again.',
|
||||||
|
], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$storedThumbnails = false;
|
$storedThumbnails = false;
|
||||||
@@ -781,10 +799,20 @@ class StoryController extends Controller
|
|||||||
$image = $manager->read($sourcePath);
|
$image = $manager->read($sourcePath);
|
||||||
|
|
||||||
$thumb = $image->scaleDown(width: 420);
|
$thumb = $image->scaleDown(width: 420);
|
||||||
$disk->put($thumbnailPath, (string) $thumb->encode(new WebpEncoder(82)));
|
$thumbEncoded = (string) $thumb->encode(new WebpEncoder(82));
|
||||||
|
$disk->put($thumbnailPath, $thumbEncoded, [
|
||||||
|
'visibility' => 'public',
|
||||||
|
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||||
|
'ContentType' => 'image/webp',
|
||||||
|
]);
|
||||||
|
|
||||||
$medium = $image->scaleDown(width: 1200);
|
$medium = $image->scaleDown(width: 1200);
|
||||||
$disk->put($mediumPath, (string) $medium->encode(new WebpEncoder(85)));
|
$mediumEncoded = (string) $medium->encode(new WebpEncoder(85));
|
||||||
|
$disk->put($mediumPath, $mediumEncoded, [
|
||||||
|
'visibility' => 'public',
|
||||||
|
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||||
|
'ContentType' => 'image/webp',
|
||||||
|
]);
|
||||||
$storedThumbnails = true;
|
$storedThumbnails = true;
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$storedThumbnails = false;
|
$storedThumbnails = false;
|
||||||
@@ -792,17 +820,62 @@ class StoryController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $storedThumbnails) {
|
if (! $storedThumbnails) {
|
||||||
$disk->copy($originalPath, $thumbnailPath);
|
$disk->put($thumbnailPath, $raw, [
|
||||||
$disk->copy($originalPath, $mediumPath);
|
'visibility' => 'public',
|
||||||
|
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||||
|
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
|
||||||
|
]);
|
||||||
|
$disk->put($mediumPath, $raw, [
|
||||||
|
'visibility' => 'public',
|
||||||
|
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||||
|
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'thumbnail_url' => $disk->url($thumbnailPath),
|
'thumbnail_url' => $this->storyMediaPublicUrl($thumbnailPath),
|
||||||
'medium_url' => $disk->url($mediumPath),
|
'medium_url' => $this->storyMediaPublicUrl($mediumPath),
|
||||||
'original_url' => $disk->url($originalPath),
|
'original_url' => $this->storyMediaPublicUrl($originalPath),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function storyMediaDiskName(): string
|
||||||
|
{
|
||||||
|
return (string) config('uploads.object_storage.disk', 's3');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storyMediaPath(string $variant, string $hash, ?string $filename = null): string
|
||||||
|
{
|
||||||
|
$cleanVariant = trim($variant, '/');
|
||||||
|
$cleanHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hash));
|
||||||
|
$file = $filename ?? ($cleanHash . '.webp');
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'stories/%s/%s/%s/%s',
|
||||||
|
$cleanVariant,
|
||||||
|
substr($cleanHash, 0, 2),
|
||||||
|
substr($cleanHash, 2, 2),
|
||||||
|
ltrim($file, '/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storyMediaPublicUrl(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertStoryMediaStorageIsAllowed(): void
|
||||||
|
{
|
||||||
|
if (! app()->environment('production')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diskName = $this->storyMediaDiskName();
|
||||||
|
if (in_array($diskName, ['local', 'public'], true)) {
|
||||||
|
throw new \RuntimeException('Production story media storage must use object storage, not local/public disks.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function tag(string $tag): View
|
public function tag(string $tag): View
|
||||||
{
|
{
|
||||||
$storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail();
|
$storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creator Story model.
|
* Creator Story model.
|
||||||
@@ -143,11 +144,12 @@ class Story extends Model
|
|||||||
|
|
||||||
public function getCoverUrlAttribute(): ?string
|
public function getCoverUrlAttribute(): ?string
|
||||||
{
|
{
|
||||||
if (! $this->cover_image) {
|
return $this->resolveStoryMediaUrl($this->cover_image);
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return str_starts_with($this->cover_image, 'http') ? $this->cover_image : asset($this->cover_image);
|
public function getOgImageUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->resolveStoryMediaUrl($this->og_image);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,4 +176,30 @@ class Story extends Model
|
|||||||
|
|
||||||
return \Illuminate\Support\Str::limit($text, 160);
|
return \Illuminate\Support\Str::limit($text, 160);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveStoryMediaUrl(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (! $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($value, 'http')) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = ltrim($value, '/');
|
||||||
|
if (str_starts_with($path, 'storage/')) {
|
||||||
|
$path = substr($path, strlen('storage/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^stories/(sm|md|original)/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]+\.(webp|jpg|jpeg|png)$#', $path) === 1) {
|
||||||
|
$cdnBase = rtrim((string) config('cdn.files_url', ''), '/');
|
||||||
|
|
||||||
|
return $cdnBase !== ''
|
||||||
|
? $cdnBase . '/' . $path
|
||||||
|
: Storage::disk('public')->url($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset($value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import "node:process";
|
|||||||
import "node:path";
|
import "node:path";
|
||||||
import "node:url";
|
import "node:url";
|
||||||
import "./vendor-tooltip-CIQaDNlG.js";
|
import "./vendor-tooltip-CIQaDNlG.js";
|
||||||
import "./vendor-realtime-cgmg5qQY.js";
|
import "./vendor-realtime-5YdDOIKO.js";
|
||||||
import "buffer";
|
import "buffer";
|
||||||
import "child_process";
|
import "child_process";
|
||||||
import "net";
|
import "net";
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import require$$0 from "util";
|
import require$$0 from "util";
|
||||||
import stream from "stream";
|
import stream from "stream";
|
||||||
import require$$4 from "https";
|
|
||||||
import require$$5 from "url";
|
import require$$5 from "url";
|
||||||
import require$$6 from "fs";
|
import require$$6 from "fs";
|
||||||
import require$$1 from "crypto";
|
import require$$1 from "crypto";
|
||||||
import require$$4$2 from "assert";
|
import require$$4$2 from "assert";
|
||||||
import require$$1$1 from "buffer";
|
import require$$1$1 from "buffer";
|
||||||
import require$$2 from "child_process";
|
import require$$2 from "child_process";
|
||||||
import require$$4$1 from "events";
|
|
||||||
import require$$8 from "net";
|
import require$$8 from "net";
|
||||||
import require$$10 from "tls";
|
import require$$10 from "tls";
|
||||||
import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DSw66HfW.js";
|
import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DSw66HfW.js";
|
||||||
|
import require$$4$1 from "events";
|
||||||
import require$$3 from "http";
|
import require$$3 from "http";
|
||||||
|
import require$$4 from "https";
|
||||||
class u {
|
class u {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated";
|
this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated";
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
|
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
|
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
|
||||||
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
|
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
|
||||||
@@ -97,46 +97,46 @@
|
|||||||
"/build/assets/vendor-tiptap-DSw66HfW.js"
|
"/build/assets/vendor-tiptap-DSw66HfW.js"
|
||||||
],
|
],
|
||||||
"\u0000assert?commonjs-external": [
|
"\u0000assert?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000buffer?commonjs-external": [
|
"\u0000buffer?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000child_process?commonjs-external": [
|
"\u0000child_process?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000commonjsHelpers.js": [
|
"\u0000commonjsHelpers.js": [
|
||||||
"/build/assets/vendor-tiptap-DSw66HfW.js"
|
"/build/assets/vendor-tiptap-DSw66HfW.js"
|
||||||
],
|
],
|
||||||
"\u0000crypto?commonjs-external": [
|
"\u0000crypto?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000events?commonjs-external": [
|
"\u0000events?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000fs?commonjs-external": [
|
"\u0000fs?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000http?commonjs-external": [
|
"\u0000http?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000https?commonjs-external": [
|
"\u0000https?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000net?commonjs-external": [
|
"\u0000net?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000stream?commonjs-external": [
|
"\u0000stream?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000tls?commonjs-external": [
|
"\u0000tls?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000url?commonjs-external": [
|
"\u0000url?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"\u0000util?commonjs-external": [
|
"\u0000util?commonjs-external": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"node_modules/@emoji-mart/data/sets/15/native.json": [
|
"node_modules/@emoji-mart/data/sets/15/native.json": [
|
||||||
"/build/assets/emoji-data-4xGXbtDn.js"
|
"/build/assets/emoji-data-4xGXbtDn.js"
|
||||||
@@ -999,7 +999,7 @@
|
|||||||
"node_modules/inline-style-parser/cjs/index.js": [],
|
"node_modules/inline-style-parser/cjs/index.js": [],
|
||||||
"node_modules/is-plain-obj/index.js": [],
|
"node_modules/is-plain-obj/index.js": [],
|
||||||
"node_modules/laravel-echo/dist/echo.js": [
|
"node_modules/laravel-echo/dist/echo.js": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"node_modules/linkifyjs/dist/linkify.mjs": [
|
"node_modules/linkifyjs/dist/linkify.mjs": [
|
||||||
"/build/assets/vendor-tiptap-DSw66HfW.js"
|
"/build/assets/vendor-tiptap-DSw66HfW.js"
|
||||||
@@ -1895,7 +1895,7 @@
|
|||||||
],
|
],
|
||||||
"node_modules/proxy-from-env/index.js": [],
|
"node_modules/proxy-from-env/index.js": [],
|
||||||
"node_modules/pusher-js/dist/node/pusher.js": [
|
"node_modules/pusher-js/dist/node/pusher.js": [
|
||||||
"/build/assets/vendor-realtime-cgmg5qQY.js"
|
"/build/assets/vendor-realtime-5YdDOIKO.js"
|
||||||
],
|
],
|
||||||
"node_modules/qs/lib/formats.js": [],
|
"node_modules/qs/lib/formats.js": [],
|
||||||
"node_modules/qs/lib/index.js": [],
|
"node_modules/qs/lib/index.js": [],
|
||||||
@@ -1998,6 +1998,7 @@
|
|||||||
"resources/js/Pages/Admin/AiBiography.jsx": [],
|
"resources/js/Pages/Admin/AiBiography.jsx": [],
|
||||||
"resources/js/Pages/Admin/Artworks.jsx": [],
|
"resources/js/Pages/Admin/Artworks.jsx": [],
|
||||||
"resources/js/Pages/Admin/AuthAudit.jsx": [],
|
"resources/js/Pages/Admin/AuthAudit.jsx": [],
|
||||||
|
"resources/js/Pages/Admin/DailyActivity.jsx": [],
|
||||||
"resources/js/Pages/Admin/Dashboard.jsx": [],
|
"resources/js/Pages/Admin/Dashboard.jsx": [],
|
||||||
"resources/js/Pages/Admin/FeaturedArtworks.jsx": [],
|
"resources/js/Pages/Admin/FeaturedArtworks.jsx": [],
|
||||||
"resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx": [],
|
"resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx": [],
|
||||||
@@ -2193,7 +2194,7 @@
|
|||||||
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
|
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
|
||||||
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
|
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
|
||||||
"resources/js/components/artwork/ArtworkShareModal.jsx": [
|
"resources/js/components/artwork/ArtworkShareModal.jsx": [
|
||||||
"/build/assets/ArtworkShareModal-DEVyX6r2.js"
|
"/build/assets/ArtworkShareModal-LimHRcZ1.js"
|
||||||
],
|
],
|
||||||
"resources/js/components/artwork/ArtworkTags.jsx": [],
|
"resources/js/components/artwork/ArtworkTags.jsx": [],
|
||||||
"resources/js/components/artwork/AuthorBioPopover.jsx": [],
|
"resources/js/components/artwork/AuthorBioPopover.jsx": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1407
news_dates.csv
Normal file
1407
news_dates.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
|||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
|
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
|
||||||
|
{ label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
271
resources/js/Pages/Admin/DailyActivity.jsx
Normal file
271
resources/js/Pages/Admin/DailyActivity.jsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Head, router } from '@inertiajs/react'
|
||||||
|
import AdminLayout from '../../Layouts/AdminLayout'
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '—'
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(value))
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value, tone = 'sky' }) {
|
||||||
|
const tones = {
|
||||||
|
sky: 'border-sky-400/20 bg-sky-400/10 text-sky-200',
|
||||||
|
rose: 'border-rose-400/20 bg-rose-400/10 text-rose-200',
|
||||||
|
amber: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
|
||||||
|
emerald: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-5 ${tones[tone]}`}>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/65">{label}</div>
|
||||||
|
<div className="mt-2 text-3xl font-bold text-white">{Number(value || 0).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-black/20 text-lg text-white/80">
|
||||||
|
<i className={icon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ title, subtitle, actionHref, actionLabel, children }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-3xl border border-white/10 bg-white/[0.04] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.35)]">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/8 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||||
|
{subtitle ? <p className="mt-1 text-sm text-slate-400">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actionHref ? (
|
||||||
|
<a href={actionHref} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200 transition hover:bg-white/[0.08]">
|
||||||
|
<span>{actionLabel || 'Open queue'}</span>
|
||||||
|
<i className="fa-solid fa-arrow-up-right-from-square text-[10px]" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ label }) {
|
||||||
|
return <div className="rounded-2xl border border-dashed border-white/10 bg-black/10 px-4 py-6 text-sm text-slate-400">{label}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTable({ columns, rows, emptyLabel }) {
|
||||||
|
if (!rows?.length) {
|
||||||
|
return <EmptyState label={emptyLabel} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-white/10 text-sm text-slate-200">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-[11px] uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column.key} className="px-3 py-3 font-semibold">{column.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/6">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={row.id || `${index}-${row.created_at || row.updated_at || 'row'}`} className="align-top">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.key} className="px-3 py-3 text-slate-200/90">
|
||||||
|
{column.render ? column.render(row) : row[column.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DailyActivity({ selectedDate, summary, queues, sections }) {
|
||||||
|
const onDateChange = (event) => {
|
||||||
|
router.get('/moderation/activity', { date: event.target.value }, { preserveState: true, replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Daily Activity" subtitle="A day-by-day moderation cockpit for reviewing new content, queue movement, and staff actions.">
|
||||||
|
<Head title="Admin · Daily Activity" />
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(135deg,rgba(244,63,94,0.18),rgba(15,23,42,0.75))] p-6 shadow-[0_30px_120px_rgba(15,23,42,0.5)]">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-5">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.26em] text-rose-200/80">Moderation review</div>
|
||||||
|
<h2 className="mt-2 text-2xl font-bold text-white">Selected day: {selectedDate}</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-200/80">
|
||||||
|
This view pulls together the moderation-adjacent activity for the selected day so admins can triage queues and jump into the right review surface quickly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">Day</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={onDateChange}
|
||||||
|
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none transition focus:border-rose-400/50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<StatCard icon="fa-solid fa-user-plus" label="New Users" value={summary.new_users} tone="sky" />
|
||||||
|
<StatCard icon="fa-solid fa-images" label="New Artworks" value={summary.new_artworks} tone="rose" />
|
||||||
|
<StatCard icon="fa-solid fa-feather-pointed" label="New Stories" value={summary.new_stories} tone="amber" />
|
||||||
|
<StatCard icon="fa-solid fa-cloud-arrow-up" label="Upload Events" value={summary.upload_events} tone="emerald" />
|
||||||
|
<StatCard icon="fa-solid fa-flag" label="Report Events" value={summary.report_events} tone="rose" />
|
||||||
|
<StatCard icon="fa-solid fa-fingerprint" label="Auth Events" value={summary.auth_events} tone="sky" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-5">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Queues right now</div>
|
||||||
|
<div className="mt-4 space-y-3 text-sm text-slate-300">
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-white/8 bg-black/10 px-4 py-3">
|
||||||
|
<span>Pending uploads</span>
|
||||||
|
<span className="font-semibold text-white">{queues.pending_uploads}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-white/8 bg-black/10 px-4 py-3">
|
||||||
|
<span>Open reports</span>
|
||||||
|
<span className="font-semibold text-white">{queues.open_reports}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border border-white/8 bg-black/10 px-4 py-3">
|
||||||
|
<span>Pending username requests</span>
|
||||||
|
<span className="font-semibold text-white">{queues.pending_username_requests}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-5 lg:col-span-2">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Moderation throughput on this day</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-black/10 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Moderated uploads</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-white">{summary.moderated_uploads}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-black/10 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Moderated reports</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-white">{summary.moderated_reports}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/8 bg-black/10 p-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">Username events</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-white">{summary.username_events}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<SectionCard title="Uploads" subtitle="New uploads and same-day moderation decisions." actionHref="/moderation/uploads" actionLabel="Open uploads">
|
||||||
|
<DataTable
|
||||||
|
emptyLabel="No upload activity on this day."
|
||||||
|
rows={sections.uploads}
|
||||||
|
columns={[
|
||||||
|
{ key: 'title', label: 'Upload', render: (row) => <div><div className="font-semibold text-white">{row.title}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{row.type} · {row.status} · {row.processing_state}</div></div> },
|
||||||
|
{ key: 'moderation_status', label: 'Moderation', render: (row) => <div><div>{row.moderation_status}</div><div className="mt-1 text-xs text-slate-500">{row.moderation_note || 'No note'}</div></div> },
|
||||||
|
{ key: 'created_at', label: 'Created', render: (row) => formatDateTime(row.created_at) },
|
||||||
|
{ key: 'moderated_at', label: 'Moderated', render: (row) => formatDateTime(row.moderated_at) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Reports" subtitle="User reports created or reviewed during the selected day.">
|
||||||
|
<DataTable
|
||||||
|
emptyLabel="No report activity on this day."
|
||||||
|
rows={sections.reports}
|
||||||
|
columns={[
|
||||||
|
{ key: 'reason', label: 'Report', render: (row) => <div><div className="font-semibold text-white">{row.reason}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{row.status} · {row.target_type} #{row.target_id}</div></div> },
|
||||||
|
{ key: 'reporter', label: 'Reporter', render: (row) => row.reporter ? `@${row.reporter.username}` : '—' },
|
||||||
|
{ key: 'target', label: 'Target', render: (row) => row.target?.title || row.target?.context || 'Resolved via moderation target' },
|
||||||
|
{ key: 'last_moderated_at', label: 'Reviewed', render: (row) => <div><div>{formatDateTime(row.last_moderated_at)}</div><div className="mt-1 text-xs text-slate-500">{row.last_moderated_by ? `@${row.last_moderated_by.username}` : 'Unassigned'}</div></div> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<SectionCard title="Username Requests" subtitle="Requests created or reviewed on this day." actionHref="/moderation/usernames/moderation" actionLabel="Open usernames">
|
||||||
|
<DataTable
|
||||||
|
emptyLabel="No username activity on this day."
|
||||||
|
rows={sections.username_requests}
|
||||||
|
columns={[
|
||||||
|
{ key: 'requested_username', label: 'Request', render: (row) => <div><div className="font-semibold text-white">{row.requested_username}</div><div className="mt-1 text-xs text-slate-500">Current: {row.current_username || row.current_name || 'Unknown user'}</div></div> },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'created_at', label: 'Created', render: (row) => formatDateTime(row.created_at) },
|
||||||
|
{ key: 'reviewed_at', label: 'Reviewed', render: (row) => formatDateTime(row.reviewed_at) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Auth Audit" subtitle="Authentication events for the selected day." actionHref="/moderation/auth-audit" actionLabel="Open audit">
|
||||||
|
<DataTable
|
||||||
|
emptyLabel="No auth audit events on this day."
|
||||||
|
rows={sections.auth_events}
|
||||||
|
columns={[
|
||||||
|
{ key: 'event_type', label: 'Event', render: (row) => <div><div className="font-semibold text-white">{row.event_type}</div><div className="mt-1 text-xs text-slate-500">{row.status} · {row.ip || 'No IP'}</div></div> },
|
||||||
|
{ key: 'user', label: 'User', render: (row) => row.user ? `@${row.user.username || row.user.name}` : (row.identifier || 'Guest') },
|
||||||
|
{ key: 'reason', label: 'Reason', render: (row) => row.reason || '—' },
|
||||||
|
{ key: 'created_at', label: 'When', render: (row) => formatDateTime(row.created_at) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-3">
|
||||||
|
<SectionCard title="Users" subtitle="Accounts created on this day." actionHref="/moderation/users" actionLabel="Open users">
|
||||||
|
<DataTable
|
||||||
|
emptyLabel="No new users on this day."
|
||||||
|
rows={sections.users}
|
||||||
|
columns={[
|
||||||
|
{ key: 'username', label: 'User', render: (row) => <div><div className="font-semibold text-white">{row.username ? `@${row.username}` : row.name}</div><div className="mt-1 text-xs text-slate-500">{row.email}</div></div> },
|
||||||
|
{ key: 'role', label: 'Role' },
|
||||||
|
{ key: 'created_at', label: 'Joined', render: (row) => formatDateTime(row.created_at) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Artworks" subtitle="Artwork records created on this day." actionHref="/moderation/artworks" actionLabel="Open artworks">
|
||||||
|
<DataTable
|
||||||
|
emptyLabel="No artwork activity on this day."
|
||||||
|
rows={sections.artworks}
|
||||||
|
columns={[
|
||||||
|
{ key: 'title', label: 'Artwork', render: (row) => <div><div className="font-semibold text-white">{row.title}</div><div className="mt-1 text-xs text-slate-500">{row.user?.username ? `@${row.user.username}` : 'Unknown artist'}</div></div> },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'created_at', label: 'Created', render: (row) => formatDateTime(row.created_at) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Stories" subtitle="Creator stories created on this day." actionHref="/moderation/stories" actionLabel="Open stories">
|
||||||
|
<DataTable
|
||||||
|
emptyLabel="No story activity on this day."
|
||||||
|
rows={sections.stories}
|
||||||
|
columns={[
|
||||||
|
{ key: 'title', label: 'Story', render: (row) => <div><div className="font-semibold text-white">{row.title}</div><div className="mt-1 text-xs text-slate-500">{row.creator?.username ? `@${row.creator.username}` : 'Unknown creator'}</div></div> },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'created_at', label: 'Created', render: (row) => formatDateTime(row.created_at) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export default function Dashboard({ stats }) {
|
|||||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-slate-500">Quick Actions</h2>
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-slate-500">Quick Actions</h2>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{[
|
{[
|
||||||
|
{ label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day', desc: 'Review everything created or moderated on a selected day' },
|
||||||
{ label: 'Manage Users', href: '/moderation/users', icon: 'fa-solid fa-users', desc: 'Search, promote or demote users' },
|
{ label: 'Manage Users', href: '/moderation/users', icon: 'fa-solid fa-users', desc: 'Search, promote or demote users' },
|
||||||
{ label: 'Staff Roles', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved', desc: 'View all admins, managers and editorial staff' },
|
{ label: 'Staff Roles', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved', desc: 'View all admins, managers and editorial staff' },
|
||||||
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge', desc: 'Review pending username requests' },
|
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge', desc: 'Review pending username requests' },
|
||||||
|
|||||||
@@ -25,13 +25,13 @@
|
|||||||
'id' => $story->id,
|
'id' => $story->id,
|
||||||
'title' => old('title', (string) $story->title),
|
'title' => old('title', (string) $story->title),
|
||||||
'excerpt' => old('excerpt', (string) ($story->excerpt ?? '')),
|
'excerpt' => old('excerpt', (string) ($story->excerpt ?? '')),
|
||||||
'cover_image' => old('cover_image', (string) ($story->cover_image ?? '')),
|
'cover_image' => old('cover_image', (string) ($story->cover_url ?? $story->cover_image ?? '')),
|
||||||
'story_type' => old('story_type', (string) ($story->story_type ?? 'creator_story')),
|
'story_type' => old('story_type', (string) ($story->story_type ?? 'creator_story')),
|
||||||
'tags_csv' => old('tags_csv', (string) ($story->tags?->pluck('name')->implode(', ') ?? '')),
|
'tags_csv' => old('tags_csv', (string) ($story->tags?->pluck('name')->implode(', ') ?? '')),
|
||||||
'meta_title' => old('meta_title', (string) ($story->meta_title ?? $story->title ?? '')),
|
'meta_title' => old('meta_title', (string) ($story->meta_title ?? $story->title ?? '')),
|
||||||
'meta_description' => old('meta_description', (string) ($story->meta_description ?? $story->excerpt ?? '')),
|
'meta_description' => old('meta_description', (string) ($story->meta_description ?? $story->excerpt ?? '')),
|
||||||
'canonical_url' => old('canonical_url', (string) ($story->canonical_url ?? '')),
|
'canonical_url' => old('canonical_url', (string) ($story->canonical_url ?? '')),
|
||||||
'og_image' => old('og_image', (string) ($story->og_image ?? $story->cover_image ?? '')),
|
'og_image' => old('og_image', (string) ($story->og_image_url ?? $story->cover_url ?? $story->cover_image ?? '')),
|
||||||
'status' => old('status', (string) ($story->status ?? 'draft')),
|
'status' => old('status', (string) ($story->status ?? 'draft')),
|
||||||
'scheduled_for' => old('scheduled_for', optional($story->scheduled_for)->format('Y-m-d\\TH:i')),
|
'scheduled_for' => old('scheduled_for', optional($story->scheduled_for)->format('Y-m-d\\TH:i')),
|
||||||
'content' => $initialContent,
|
'content' => $initialContent,
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
<div class="mx-auto grid max-w-7xl gap-6 lg:grid-cols-12">
|
<div class="mx-auto grid max-w-7xl gap-6 lg:grid-cols-12">
|
||||||
<article class="lg:col-span-8">
|
<article class="lg:col-span-8">
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
|
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
|
||||||
@if($story->cover_image)
|
@if($story->cover_url)
|
||||||
<img src="{{ $story->cover_image }}" alt="{{ $story->title }}" class="h-72 w-full object-cover" />
|
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}" class="h-72 w-full object-cover" />
|
||||||
@endif
|
@endif
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="mb-4 flex flex-wrap items-center gap-3 text-xs text-gray-300">
|
<div class="mb-4 flex flex-wrap items-center gap-3 text-xs text-gray-300">
|
||||||
|
|||||||
@@ -975,6 +975,7 @@ Route::middleware(['auth', 'admin.access'])
|
|||||||
->name('admin.')
|
->name('admin.')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
|
Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
|
||||||
|
Route::get('/activity', [AdminController::class, 'dailyActivity'])->name('activity');
|
||||||
Route::get('/users', [AdminController::class, 'users'])->name('users');
|
Route::get('/users', [AdminController::class, 'users'])->name('users');
|
||||||
Route::patch('/users/{user}/role', [AdminController::class, 'updateRole'])->name('users.role');
|
Route::patch('/users/{user}/role', [AdminController::class, 'updateRole'])->name('users.role');
|
||||||
Route::get('/stories', [AdminController::class, 'stories'])->name('stories');
|
Route::get('/stories', [AdminController::class, 'stories'])->name('stories');
|
||||||
|
|||||||
39
tests/Feature/Admin/ModerationDailyActivityPageTest.php
Normal file
39
tests/Feature/Admin/ModerationDailyActivityPageTest.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Testing\Fluent\AssertableJson;
|
||||||
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class ModerationDailyActivityPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_admin_can_open_daily_activity_page_with_selected_date(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create([
|
||||||
|
'role' => 'admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get('/moderation/activity?date=2026-04-29')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Admin/DailyActivity')
|
||||||
|
->where('selectedDate', '2026-04-29')
|
||||||
|
->where('summary.new_users', 0)
|
||||||
|
->where('queues.pending_uploads', 0)
|
||||||
|
->has('sections.users')
|
||||||
|
->has('sections.artworks')
|
||||||
|
->has('sections.stories')
|
||||||
|
->has('sections.uploads')
|
||||||
|
->has('sections.reports')
|
||||||
|
->has('sections.username_requests')
|
||||||
|
->has('sections.auth_events'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ it('creator can create a draft story from editor form', function () {
|
|||||||
expect($story->status)->toBe('draft');
|
expect($story->status)->toBe('draft');
|
||||||
expect($story->slug)->not->toBe('');
|
expect($story->slug)->not->toBe('');
|
||||||
|
|
||||||
$response->assertRedirect(route('creator.stories.edit', ['story' => $story->id]));
|
$response->assertRedirect(route('studio.stories.edit', ['story' => $story->id]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creator autosave updates draft fields and creates tags from csv', function () {
|
it('creator autosave updates draft fields and creates tags from csv', function () {
|
||||||
|
|||||||
36
tests/Feature/Stories/StoryCoverCdnTest.php
Normal file
36
tests/Feature/Stories/StoryCoverCdnTest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Story;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('hydrates story edit forms with cdn-backed cover urls', function (): void {
|
||||||
|
config()->set('cdn.files_url', 'https://cdn.skinbase.test');
|
||||||
|
|
||||||
|
$creator = User::factory()->create();
|
||||||
|
$story = Story::query()->create([
|
||||||
|
'creator_id' => $creator->id,
|
||||||
|
'title' => 'Cover Story',
|
||||||
|
'slug' => 'cover-story-' . Str::lower(Str::random(6)),
|
||||||
|
'content' => '<p>Cover story content</p>',
|
||||||
|
'story_type' => 'creator_story',
|
||||||
|
'status' => 'draft',
|
||||||
|
'cover_image' => 'stories/md/a1/b2/a1b2c3d4e5f60123456789abcdef0123456789abcdef0123456789abcdef0123.webp',
|
||||||
|
'og_image' => 'stories/md/a1/b2/a1b2c3d4e5f60123456789abcdef0123456789abcdef0123456789abcdef0123.webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($creator)->get(route('creator.stories.edit', ['story' => $story->id]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$html = $response->getContent();
|
||||||
|
$this->assertNotFalse($html);
|
||||||
|
|
||||||
|
expect($html)->toContain('https:\/\/cdn.skinbase.test\/stories\/md\/a1\/b2\/a1b2c3d4e5f60123456789abcdef0123456789abcdef0123456789abcdef0123.webp');
|
||||||
|
expect($html)->not->toContain('https:\/\/skinbase.org\/stories\/md\/a1\/b2\/a1b2c3d4e5f60123456789abcdef0123456789abcdef0123456789abcdef0123.webp');
|
||||||
|
});
|
||||||
37
tests/Feature/Stories/StoryImageUploadTest.php
Normal file
37
tests/Feature/Stories/StoryImageUploadTest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('uploads story editor images to object storage with hashed cdn urls', function (): void {
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
config()->set('uploads.object_storage.disk', 's3');
|
||||||
|
config()->set('cdn.files_url', 'https://cdn.skinbase.test');
|
||||||
|
|
||||||
|
$creator = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($creator)->postJson(route('api.story.upload-image'), [
|
||||||
|
'image' => UploadedFile::fake()->image('cover.png', 1600, 900),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$mediumUrl = (string) $response->json('medium_url');
|
||||||
|
$thumbnailUrl = (string) $response->json('thumbnail_url');
|
||||||
|
$originalUrl = (string) $response->json('original_url');
|
||||||
|
|
||||||
|
expect($mediumUrl)->toMatch('#^https://cdn\.skinbase\.test/stories/md/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{64}\.webp$#');
|
||||||
|
expect($thumbnailUrl)->toMatch('#^https://cdn\.skinbase\.test/stories/sm/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{64}\.webp$#');
|
||||||
|
expect($originalUrl)->toMatch('#^https://cdn\.skinbase\.test/stories/original/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{64}\.(jpg|jpeg|png|webp)$#');
|
||||||
|
|
||||||
|
Storage::disk('s3')->assertExists(ltrim(parse_url($mediumUrl, PHP_URL_PATH) ?: '', '/'));
|
||||||
|
Storage::disk('s3')->assertExists(ltrim(parse_url($thumbnailUrl, PHP_URL_PATH) ?: '', '/'));
|
||||||
|
Storage::disk('s3')->assertExists(ltrim(parse_url($originalUrl, PHP_URL_PATH) ?: '', '/'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user