Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -7,6 +7,10 @@ Route::middleware(['web', 'throttle:60,1'])
->get('categories', [\App\Http\Controllers\CategoryController::class, 'index'])
->name('api.categories.index');
Route::middleware(['web', 'throttle:120,1'])
->post('worlds/analytics/events', [\App\Http\Controllers\Api\WorldAnalyticsEventController::class, 'store'])
->name('api.worlds.analytics.events.store');
Route::middleware(['web', 'auth'])->prefix('dashboard')->name('api.dashboard.')->group(function () {
Route::get('activity', [DashboardController::class, 'activity'])->name('activity');
Route::get('analytics', [DashboardController::class, 'analytics'])->name('analytics');
@@ -139,6 +143,13 @@ Route::prefix('rank')->name('api.rank.')->middleware(['throttle:60,1'])->group(f
// ── Studio Pro API (authenticated) ─────────────────────────────────────────────
Route::middleware(['web', 'auth'])->prefix('studio')->name('api.studio.')->group(function () {
Route::post('events', [\App\Http\Controllers\Studio\StudioEventsApiController::class, 'store'])->name('events.store');
Route::get('upload-queue', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'index'])->name('upload-queue.index');
Route::post('upload-queue/batches', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'store'])->name('upload-queue.store');
Route::post('upload-queue/bulk', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'bulk'])->name('upload-queue.bulk');
Route::post('upload-queue/items/{id}/fail', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'markFailed'])->whereNumber('id')->name('upload-queue.items.fail');
Route::post('upload-queue/items/{id}/retry', [\App\Http\Controllers\Studio\StudioUploadQueueApiController::class, 'retry'])->whereNumber('id')->name('upload-queue.items.retry');
Route::post('news/media/upload', [\App\Http\Controllers\Studio\StudioNewsMediaApiController::class, 'store'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('news.media.upload');
Route::delete('news/media', [\App\Http\Controllers\Studio\StudioNewsMediaApiController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('news.media.destroy');
Route::post('worlds/media/upload', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'store'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.upload');
Route::delete('worlds/media', [\App\Http\Controllers\Studio\StudioWorldMediaApiController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:api_write'])->name('worlds.media.destroy');
Route::put('preferences', [\App\Http\Controllers\Studio\StudioPreferencesApiController::class, 'updatePreferences'])->name('preferences.settings');
@@ -709,6 +720,21 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:20,1'])->group
->name('api.artworks.comments.destroy');
});
Route::middleware(['web', 'throttle:60,1'])
->get('news/articles/{id}/comments', [\App\Http\Controllers\Api\NewsArticleCommentController::class, 'index'])
->whereNumber('id')
->name('api.news.comments.index');
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:20,1'])->group(function () {
Route::post('news/articles/{id}/comments', [\App\Http\Controllers\Api\NewsArticleCommentController::class, 'store'])
->whereNumber('id')
->name('api.news.comments.store');
Route::delete('news/articles/{id}/comments/{commentId}', [\App\Http\Controllers\Api\NewsArticleCommentController::class, 'destroy'])
->whereNumber(['id', 'commentId'])
->name('api.news.comments.destroy');
});
// ── Reactions ─────────────────────────────────────────────────────────────────
// GET /api/artworks/{id}/reactions list artwork reaction totals (public)
// POST /api/artworks/{id}/reactions toggle artwork reaction (auth)
@@ -723,6 +749,10 @@ Route::middleware(['web', 'throttle:reactions-read'])->group(function () {
Route::get('comments/{id}/reactions', [\App\Http\Controllers\Api\ReactionController::class, 'commentReactions'])
->whereNumber('id')
->name('api.comments.reactions.index');
Route::get('news/comments/{id}/reactions', [\App\Http\Controllers\Api\ReactionController::class, 'newsCommentReactions'])
->whereNumber('id')
->name('api.news.comments.reactions.index');
});
Route::middleware(['web', 'auth', 'normalize.username', 'throttle:reactions-write'])->group(function () {
@@ -733,6 +763,10 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:reactions-writ
Route::post('comments/{id}/reactions', [\App\Http\Controllers\Api\ReactionController::class, 'toggleCommentReaction'])
->whereNumber('id')
->name('api.comments.reactions.toggle');
Route::post('news/comments/{id}/reactions', [\App\Http\Controllers\Api\ReactionController::class, 'toggleNewsCommentReaction'])
->whereNumber('id')
->name('api.news.comments.reactions.toggle');
});
// ── Personalised suggestions (auth required) ────────────────────────────────

View File

@@ -18,14 +18,14 @@ Artisan::command('uploads:cleanup {--limit=100 : Maximum drafts to clean in one
// ── Scheduled tasks ────────────────────────────────────────────────────────────
// Recalculate trending scores every 30 minutes (staggered: 24h first, then 7d)
// Recalculate trending scores every 30 minutes, staggered away from other hot paths.
Schedule::command('skinbase:recalculate-trending --period=24h')
->everyThirtyMinutes()
->cron('6,36 * * * *')
->name('trending-24h')
->withoutOverlapping();
Schedule::command('skinbase:recalculate-trending --period=7d --skip-index')
->everyThirtyMinutes()
->cron('19,49 * * * *')
->name('trending-7d')
->runInBackground()
->withoutOverlapping();
@@ -39,7 +39,7 @@ Schedule::command('skinbase:reset-windowed-stats --period=24h')
->withoutOverlapping();
Schedule::command('skinbase:reset-windowed-stats --period=7d')
->weeklyOn(1, '03:30') // Monday 03:30
->weeklyOn(1, '03:50') // Monday 03:50
->name('reset-windowed-stats-7d')
->withoutOverlapping();
@@ -48,11 +48,12 @@ Schedule::command('uploads:cleanup')->dailyAt('03:00');
Schedule::command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
Schedule::command('analytics:aggregate-feed')->dailyAt('03:20');
Schedule::command('analytics:aggregate-discovery-feedback')->dailyAt('03:25');
Schedule::command('analytics:aggregate-tag-interactions')->dailyAt('03:35');
// Drain Redis artwork-stat delta queue so MySQL counters stay fresh.
// Run every 5 minutes with overlap protection.
// Offset this off the :00/:10 boundaries so it does not pile onto publish jobs.
Schedule::command('skinbase:flush-redis-stats')
->everyFiveMinutes()
->cron('1,11,21,31,41,51 * * * *')
->name('flush-redis-stats')
->withoutOverlapping();
@@ -116,21 +117,29 @@ Schedule::command('nova-cards:publish-scheduled')
->runInBackground();
Schedule::command('collections:sync-lifecycle')
->everyTenMinutes()
->cron('3,13,23,33,43,53 * * * *')
->name('sync-collection-lifecycle')
->withoutOverlapping()
->runInBackground();
Schedule::command('homepage:warm-guest-cache')
->everyTenMinutes()
->cron('5,15,25,35,45,55 * * * *')
->name('warm-homepage-guest-cache')
->withoutOverlapping()
->runInBackground();
// Safety-net audit for Meilisearch drift on recently touched artworks.
// Scans the last 65 minutes to cover the previous hour plus a small buffer.
Schedule::command('artworks:search-reconcile --repair --reverse --remove-unexpected --limit=1000 --recent-minutes=65')
->hourlyAt(28)
->name('artworks-search-reconcile-recent')
->withoutOverlapping()
->runInBackground();
// ── Feed 2.0: Trending Cache Warm-up ─────────────────────────────────────────
// Warm the post trending cache every 2 minutes (complements the 2-min TTL).
// Warm the post trending cache every 2 minutes on odd minutes to avoid :00/:10 pileups.
Schedule::command('posts:warm-trending')
->everyTwoMinutes()
->cron('1-59/2 * * * *')
->name('warm-post-trending')
->withoutOverlapping();
@@ -138,7 +147,7 @@ Schedule::command('posts:warm-trending')
// Recalculate ranking_score + engagement_velocity every 30 minutes.
// Also syncs V2 scores to rank_artwork_scores so list builds benefit.
Schedule::command('nova:recalculate-rankings --sync-rank-scores')
->everyThirtyMinutes()
->cron('7,37 * * * *')
->name('ranking-v2')
->withoutOverlapping()
->runInBackground();
@@ -146,21 +155,30 @@ Schedule::command('nova:recalculate-rankings --sync-rank-scores')
// ── Rising Engine (Heat / Momentum) ───────────────────────────────────────────
// Snapshot current totals each hour, then recalculate heat every 15 minutes.
Schedule::command('nova:metrics-snapshot-hourly')
->hourly()
->hourlyAt(2)
->name('metrics-snapshot-hourly')
->withoutOverlapping()
->runInBackground();
Schedule::command('nova:recalculate-heat')
->everyFifteenMinutes()
->cron('9,24,39,54 * * * *')
->name('recalculate-heat')
->withoutOverlapping()
->runInBackground();
// Additional production schedules that must live here because Laravel 11's
// active scheduler in this app is defined in routes/console.php, not Kernel.
// Generate static sitemap XML files that nginx can serve directly without PHP.
// The generate command writes public/sitemap.xml + public/sitemaps/{name}.xml.
Schedule::command('skinbase:sitemaps:generate')
->dailyAt('22:30')
->name('sitemaps-generate')
->withoutOverlapping()
->runInBackground();
Schedule::command('skinbase:sitemaps:publish --sync')
->everySixHours()
->cron('8 */6 * * *')
->name('sitemaps-publish')
->withoutOverlapping()
->runInBackground();
@@ -171,13 +189,15 @@ Schedule::command('skinbase:sitemaps:validate')
->withoutOverlapping()
->runInBackground();
// Keep the old release-pipeline cleanup running so stale release artifacts are pruned.
Schedule::job(new \App\Jobs\Sitemaps\CleanupSitemapReleasesJob())
->dailyAt('05:00')
->name('sitemaps-cleanup')
->withoutOverlapping();
Schedule::command('collections:dispatch-maintenance')
->hourly()
->hourlyAt(43)
->name('dispatch-collection-maintenance')
->withoutOverlapping()
->runInBackground();
@@ -202,7 +222,7 @@ Schedule::job(new \App\Jobs\RebuildTrendingNovaCardsJob())
->withoutOverlapping();
Schedule::job(new \App\Jobs\RecalculateRisingNovaCardsJob())
->everyFifteenMinutes()
->cron('12,27,42,57 * * * *')
->name('nova-cards-rising-cache-refresh')
->withoutOverlapping();
@@ -223,30 +243,30 @@ Schedule::command('health:tick')
->withoutOverlapping();
Schedule::command('forum:ai-scan')
->everyTenMinutes()
->hourlyAt(16)
->name('forum-ai-scan')
->withoutOverlapping()
->runInBackground();
Schedule::command('forum:bot-scan')
->everyFiveMinutes()
->hourlyAt(22)
->name('forum-bot-scan')
->withoutOverlapping()
->runInBackground();
Schedule::command('forum:scan-posts --limit=250')
->everyFifteenMinutes()
->hourlyAt(17)
->name('forum-post-scan')
->withoutOverlapping()
->runInBackground();
Schedule::command('forum:firewall-scan')
->everyFiveMinutes()
->hourlyAt(40)
->name('forum-firewall-scan')
->withoutOverlapping()
->runInBackground();
Schedule::command('horizon:snapshot')
->everyFiveMinutes()
->hourlyAt(45)
->name('horizon-snapshot')
->withoutOverlapping();

View File

@@ -13,6 +13,7 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Legacy\AvatarController;
use App\Http\Controllers\Legacy\LegacyArtworkPhotoController;
use App\Http\Controllers\Legacy\CategoryRedirectController;
use App\Http\Controllers\Community\LatestCommentsController;
use App\Http\Controllers\User\FavouritesController;
@@ -22,6 +23,13 @@ use App\Http\Controllers\Web\GalleryController;
// ── AVATARS ───────────────────────────────────────────────────────────────────
Route::get('/avatar/{id}/{name?}', [AvatarController::class, 'show'])->where('id', '\d+')->name('legacy.avatar');
// ── LEGACY THUMBNAIL / PHOTO URLS ────────────────────────────────────────────
Route::get('/photo/{encoded}_{size}.{extension}', LegacyArtworkPhotoController::class)
->where('encoded', '[0-9A-Za-z]+')
->where('size', '\d+')
->where('extension', '[A-Za-z0-9]+')
->name('legacy.photo');
// ── ARTWORK (legacy comment URL) ──────────────────────────────────────────────
//Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\d+');

View File

@@ -29,6 +29,7 @@ use App\Http\Controllers\Web\RssFeedController;
use App\Http\Controllers\Web\ApplicationController;
use App\Http\Controllers\Web\CategoryController;
use App\Http\Controllers\News\NewsController as FrontendNewsController;
use App\Http\Controllers\News\NewsArticleCommentController;
use App\Http\Controllers\News\NewsRssController;
use App\Http\Controllers\RSS\GlobalFeedController;
use App\Http\Controllers\RSS\DiscoverFeedController;
@@ -36,6 +37,7 @@ use App\Http\Controllers\RSS\ExploreFeedController;
use App\Http\Controllers\RSS\TagFeedController;
use App\Http\Controllers\RSS\CreatorFeedController;
use App\Http\Controllers\RSS\BlogFeedController;
use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Studio\StudioNewsController;
use App\Http\Controllers\Studio\StudioController;
use App\Http\Controllers\Studio\StudioWorldController;
@@ -89,6 +91,8 @@ Route::prefix('discover')->name('discover.')->group(function () {
// ── EXPLORE (/explore/*) ──────────────────────────────────────────────────────
Route::prefix('explore')->name('explore.')->group(function () {
Route::get('/', [ExploreController::class, 'index'])->name('index');
Route::get('/best', [ExploreController::class, 'hallOfFame'])->name('best');
Route::get('/top-rated', fn () => redirect()->route('explore.index', array_merge(request()->query(), ['sort' => 'top-rated']), 301))->name('top-rated');
Route::get('/members', fn () => redirect()->route('creators.top', request()->query(), 301))->name('members.redirect');
Route::get('/memebers', fn () => redirect()->route('creators.top', request()->query(), 301))->name('memebers.redirect');
Route::get('/{type}', [ExploreController::class, 'byType'])
@@ -272,6 +276,13 @@ Route::prefix('news')->name('news.')->group(function () {
->name('author');
Route::get('category/{slug}', [FrontendNewsController::class, 'category'])->name('category');
Route::get('tag/{slug}', [FrontendNewsController::class, 'tag'])->name('tag');
Route::middleware('auth')->post('{slug}/comments', [NewsArticleCommentController::class, 'store'])
->where('slug', '[a-z0-9\-]+')
->name('comments.store');
Route::middleware('auth')->delete('{slug}/comments/{comment}', [NewsArticleCommentController::class, 'destroy'])
->where('slug', '[a-z0-9\-]+')
->whereNumber('comment')
->name('comments.destroy');
Route::get('{slug}', [FrontendNewsController::class, 'show'])
->where('slug', '[a-z0-9\-]+')
->name('show');
@@ -297,7 +308,11 @@ Route::get('/worlds/create', function (Request $request) {
return redirect()->route('worlds.index', $request->query(), 302);
})
->name('worlds.create.redirect');
Route::get('/worlds/{world:slug}', [WorldController::class, 'show'])
Route::get('/worlds/{world}/{year}', [WorldController::class, 'showEdition'])
->where('world', '^(?!create$)[a-z0-9]+(?:-[a-z0-9]+)*$')
->whereNumber('year')
->name('worlds.editions.show');
Route::get('/worlds/{world}', [WorldController::class, 'show'])
->where('world', '^(?!create$)[a-z0-9]+(?:-[a-z0-9]+)*$')
->name('worlds.show');
Route::get('/collections/editorial', [\App\Http\Controllers\Web\CollectionDiscoveryController::class, 'editorial'])
@@ -388,7 +403,7 @@ Route::get('/@{username}/collections/{slug}', [ProfileCollectionController::clas
Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->where('tab', 'posts|artworks|stories|achievements|collections|about|stats|favourites|activity')
->where('tab', 'posts|artworks|stories|achievements|worlds|collections|about|stats|favourites|activity')
->name('profile.tab');
Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
@@ -448,6 +463,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
Route::get('/content', [StudioController::class, 'content'])->name('content');
Route::get('/artworks', [StudioController::class, 'artworks'])->name('artworks');
Route::get('/drafts', [StudioController::class, 'drafts'])->name('drafts');
Route::get('/upload-queue', [StudioController::class, 'uploadQueue'])->name('upload-queue');
Route::get('/scheduled', [StudioController::class, 'scheduled'])->name('scheduled');
Route::get('/calendar', [StudioController::class, 'calendar'])->name('calendar');
Route::get('/archived', [StudioController::class, 'archived'])->name('archived');
@@ -458,6 +474,8 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
Route::get('/analytics', [StudioController::class, 'analyticsOverview'])->name('analytics');
Route::get('/collections', [StudioController::class, 'collections'])->name('collections');
Route::get('/stories', [StudioController::class, 'stories'])->name('stories');
Route::get('/stories/create', [StoryController::class, 'create'])->middleware('creator.access')->name('stories.create');
Route::get('/stories/{story}/edit', [StoryController::class, 'edit'])->middleware('creator.access')->name('stories.edit');
Route::get('/assets', [StudioController::class, 'assets'])->name('assets');
Route::get('/comments', [StudioController::class, 'comments'])->name('comments');
Route::get('/activity', [StudioController::class, 'activity'])->name('activity');
@@ -489,6 +507,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
Route::get('/news/{article}/preview', [StudioNewsController::class, 'preview'])->whereNumber('article')->name('news.preview');
Route::get('/news/{article}/edit', [StudioNewsController::class, 'edit'])->whereNumber('article')->name('news.edit');
Route::patch('/news/{article}', [StudioNewsController::class, 'update'])->whereNumber('article')->name('news.update');
Route::delete('/news/{article}', [StudioNewsController::class, 'destroy'])->whereNumber('article')->name('news.destroy');
Route::post('/news/{article}/publish', [StudioNewsController::class, 'publish'])->whereNumber('article')->name('news.publish');
Route::post('/news/{article}/archive', [StudioNewsController::class, 'archive'])->whereNumber('article')->name('news.archive');
Route::post('/news/{article}/feature', [StudioNewsController::class, 'feature'])->whereNumber('article')->name('news.feature');
@@ -500,6 +519,11 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
Route::get('/worlds/{world}/preview', [StudioWorldController::class, 'preview'])->whereNumber('world')->name('worlds.preview');
Route::get('/worlds/{world}/edit', [StudioWorldController::class, 'edit'])->whereNumber('world')->name('worlds.edit');
Route::patch('/worlds/{world}', [StudioWorldController::class, 'update'])->whereNumber('world')->name('worlds.update');
Route::post('/worlds/{world}/suggestions/add', [StudioWorldController::class, 'addSuggestion'])->whereNumber('world')->name('worlds.suggestions.add');
Route::post('/worlds/{world}/suggestions/pin', [StudioWorldController::class, 'pinSuggestion'])->whereNumber('world')->name('worlds.suggestions.pin');
Route::post('/worlds/{world}/suggestions/dismiss', [StudioWorldController::class, 'dismissSuggestion'])->whereNumber('world')->name('worlds.suggestions.dismiss');
Route::post('/worlds/{world}/suggestions/not-relevant', [StudioWorldController::class, 'markSuggestionNotRelevant'])->whereNumber('world')->name('worlds.suggestions.not-relevant');
Route::post('/worlds/{world}/suggestions/restore', [StudioWorldController::class, 'restoreSuggestion'])->whereNumber('world')->name('worlds.suggestions.restore');
Route::post('/worlds/{world}/submissions/{submission}/approve', [StudioWorldController::class, 'approveSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.approve');
Route::post('/worlds/{world}/submissions/{submission}/remove', [StudioWorldController::class, 'removeSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.remove');
Route::post('/worlds/{world}/submissions/{submission}/block', [StudioWorldController::class, 'blockSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.block');
@@ -508,7 +532,10 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam
Route::post('/worlds/{world}/submissions/{submission}/feature', [StudioWorldController::class, 'featureSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.feature');
Route::post('/worlds/{world}/submissions/{submission}/unfeature', [StudioWorldController::class, 'unfeatureSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.unfeature');
Route::post('/worlds/{world}/submissions/{submission}/pending', [StudioWorldController::class, 'pendingSubmission'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.pending');
Route::post('/worlds/{world}/submissions/{submission}/rewards/{rewardType}', [StudioWorldController::class, 'grantSubmissionReward'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.rewards.grant');
Route::post('/worlds/{world}/submissions/{submission}/rewards/{rewardType}/revoke', [StudioWorldController::class, 'revokeSubmissionReward'])->whereNumber('world')->whereNumber('submission')->name('worlds.submissions.rewards.revoke');
Route::post('/worlds/{world}/publish', [StudioWorldController::class, 'publish'])->whereNumber('world')->name('worlds.publish');
Route::post('/worlds/{world}/recap/publish', [StudioWorldController::class, 'publishRecap'])->whereNumber('world')->name('worlds.recap.publish');
Route::post('/worlds/{world}/archive', [StudioWorldController::class, 'archive'])->whereNumber('world')->name('worlds.archive');
Route::post('/worlds/{world}/duplicate', [StudioWorldController::class, 'duplicate'])->whereNumber('world')->name('worlds.duplicate');
Route::post('/worlds/{world}/new-edition', [StudioWorldController::class, 'newEdition'])->whereNumber('world')->name('worlds.new-edition');
@@ -927,9 +954,71 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n
Route::get('/{id}', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'show'])->whereNumber('id')->name('show');
});
Route::middleware(['auth', 'admin.moderation'])->get('/admin/usernames/moderation', function () {
return Inertia::render('Admin/UsernameQueue');
})->name('admin.usernames.moderation');
Route::middleware(['auth'])
->prefix('admin')
->group(function () {
Route::any('/{path?}', function (?string $path = null) {
$target = '/moderation' . ($path ? '/' . $path : '');
$query = request()->getQueryString();
if ($query) {
$target .= '?' . $query;
}
return redirect($target, 302);
})->where('path', '.*');
});
// ── ADMIN PANEL ───────────────────────────────────────────────────────────────
Route::middleware(['auth', 'admin.access'])
->prefix('moderation')
->name('admin.')
->group(function () {
Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
Route::get('/users', [AdminController::class, 'users'])->name('users');
Route::patch('/users/{user}/role', [AdminController::class, 'updateRole'])->name('users.role');
Route::get('/stories', [AdminController::class, 'stories'])->name('stories');
Route::get('/artworks', [AdminController::class, 'artworks'])->name('artworks');
Route::get('/usernames/moderation', [AdminController::class, 'usernameQueue'])->name('usernames');
Route::get('/uploads', [AdminController::class, 'uploadQueue'])->name('uploads');
Route::get('/settings', [AdminController::class, 'settings'])->name('settings');
Route::middleware(['artwork.maturity.access'])
->prefix('ai-biography')
->name('cp.ai-biography.')
->group(function () {
Route::get('/', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'index'])->name('index');
Route::post('/users/{user}/rebuild', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'rebuild'])->whereNumber('user')->name('rebuild');
Route::post('/records/{biography}/approve', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'approve'])->whereNumber('biography')->name('approve');
Route::post('/records/{biography}/flag', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'flag'])->whereNumber('biography')->name('flag');
Route::post('/records/{biography}/hide', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'hide'])->whereNumber('biography')->name('hide');
Route::post('/records/{biography}/show', [\App\Http\Controllers\Settings\AiBiographyAdminController::class, 'show'])->whereNumber('biography')->name('show');
});
Route::prefix('artworks/featured')
->name('artworks.featured.')
->group(function () {
Route::get('/', [\App\Http\Controllers\Settings\FeaturedArtworkAdminController::class, 'index'])->name('main');
Route::get('/search', [\App\Http\Controllers\Settings\FeaturedArtworkAdminController::class, 'search'])->name('search');
Route::post('/', [\App\Http\Controllers\Settings\FeaturedArtworkAdminController::class, 'store'])->name('store');
Route::match(['put', 'patch'], '/{feature}', [\App\Http\Controllers\Settings\FeaturedArtworkAdminController::class, 'update'])->whereNumber('feature')->name('update');
Route::post('/{feature}/toggle', [\App\Http\Controllers\Settings\FeaturedArtworkAdminController::class, 'toggle'])->whereNumber('feature')->name('toggle');
Route::post('/{feature}/force-hero', [\App\Http\Controllers\Settings\FeaturedArtworkAdminController::class, 'toggleForceHero'])->whereNumber('feature')->name('force-hero');
Route::delete('/{feature}', [\App\Http\Controllers\Settings\FeaturedArtworkAdminController::class, 'destroy'])->whereNumber('feature')->name('delete');
});
Route::prefix('homepage/announcements')
->name('homepage-announcements.')
->group(function () {
Route::get('/', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'store'])->name('store');
Route::post('/preview', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'preview'])->name('preview');
Route::get('/{homepageAnnouncement}/edit', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'edit'])->whereNumber('homepageAnnouncement')->name('edit');
Route::match(['put', 'patch'], '/{homepageAnnouncement}', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'update'])->whereNumber('homepageAnnouncement')->name('update');
Route::delete('/{homepageAnnouncement}', [\App\Http\Controllers\Settings\HomepageAnnouncementController::class, 'destroy'])->whereNumber('homepageAnnouncement')->name('destroy');
});
});
// ── COMMUNITY ACTIVITY ────────────────────────────────────────────────────────