Files
SkinbaseNova/routes/web.php
Gregor Klevze dc51d65440 feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
2026-03-03 09:48:31 +01:00

425 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\User\ProfileController;
use App\Models\ContentType;
use App\Http\Controllers\User\AvatarController;
use App\Http\Controllers\Dashboard\ManageController;
use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkController;
use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\ArtController;
use App\Http\Controllers\Web\ArtworkPageController;
use App\Http\Controllers\Misc\AvatarController as LegacyAvatarController;
use App\Http\Controllers\Forum\ForumController;
use App\Http\Controllers\Community\NewsController;
use App\Http\Controllers\Web\CategoryController;
use App\Http\Controllers\Web\FeaturedArtworksController;
use App\Http\Controllers\Web\DailyUploadsController;
use App\Http\Controllers\Community\ChatController;
use App\Http\Controllers\User\TopFavouritesController;
use App\Http\Controllers\User\FavouritesController;
use App\Http\Controllers\User\TopAuthorsController;
use App\Http\Controllers\User\TodayInHistoryController;
use App\Http\Controllers\User\TodayDownloadsController;
use App\Http\Controllers\User\MonthlyCommentatorsController;
use App\Http\Controllers\User\MembersController;
use App\Http\Controllers\Community\LatestController;
use App\Http\Controllers\Community\LatestCommentsController;
use App\Http\Controllers\Community\InterviewController;
use App\Http\Controllers\User\StatisticsController;
use App\Http\Controllers\User\ReceivedCommentsController;
use App\Http\Controllers\Web\BrowseCategoriesController;
use App\Http\Controllers\Web\GalleryController;
use App\Http\Controllers\Web\BrowseGalleryController;
use App\Http\Controllers\Web\DiscoverController;
use Inertia\Inertia;
// ── DISCOVER routes (/discover/*) ─────────────────────────────────────────────
Route::prefix('discover')->name('discover.')->group(function () {
Route::get('/trending', [DiscoverController::class, 'trending'])->name('trending');
Route::get('/rising', [DiscoverController::class, 'rising'])->name('rising');
Route::get('/fresh', [DiscoverController::class, 'fresh'])->name('fresh');
Route::get('/top-rated', [DiscoverController::class, 'topRated'])->name('top-rated');
Route::get('/most-downloaded', [DiscoverController::class, 'mostDownloaded'])->name('most-downloaded');
Route::get('/on-this-day', [DiscoverController::class, 'onThisDay'])->name('on-this-day');
// Artworks from people you follow (auth required)
Route::middleware('auth')->get('/following', [DiscoverController::class, 'following'])->name('following');
// Personalised "For You" feed (auth required; guests → redirect)
Route::middleware('auth')->get('/for-you', [DiscoverController::class, 'forYou'])->name('for-you');
});
// ── CREATORS routes (/creators/*) ─────────────────────────────────────────────
Route::prefix('creators')->name('creators.')->group(function () {
// Top Creators → reuse existing top-authors controller
Route::get('/top', [\App\Http\Controllers\User\TopAuthorsController::class, 'index'])->name('top');
// Rising Creators → newest creators with recent uploads
Route::get('/rising', [\App\Http\Controllers\Web\DiscoverController::class, 'risingCreators'])->name('rising');
});
// Creator Stories → canonical rename of /interviews
Route::get('/stories', [\App\Http\Controllers\Community\InterviewController::class, 'index'])->name('stories');
// Tags listing page
Route::get('/tags', [\App\Http\Controllers\Web\TagController::class, 'index'])->name('tags.index');
// Following redirect (convenience shortcut for authenticated users)
Route::middleware('auth')->get('/following', function () {
return redirect()->route('dashboard.following');
})->name('following.redirect');
// Legacy route set migrated from routes/legacy.php into this file.
Route::get('/', [HomeController::class, 'index'])->name('legacy.home');
Route::get('/home', [HomeController::class, 'index']);
Route::get('/art/{id}/{slug?}', [ArtworkPageController::class, 'show'])->where('id', '\\d+')->name('art.show');
Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\\d+');
Route::get('/avatar/{id}/{name?}', [LegacyAvatarController::class, 'show'])->where('id', '\\d+')->name('legacy.avatar');
Route::middleware('ensure.onboarding.complete')->prefix('forum')->name('forum.')->group(function () {
Route::get('/', [ForumController::class, 'index'])->name('index');
Route::get('/thread/{thread}-{slug?}', [ForumController::class, 'showThread'])->name('thread.show');
Route::get('/{category:slug}', [ForumController::class, 'showCategory'])->name('category.show');
Route::middleware('auth')->group(function () {
Route::get('/{category:slug}/new', [ForumController::class, 'createThreadForm'])->name('thread.create');
Route::post('/{category:slug}/new', [ForumController::class, 'storeThread'])->name('thread.store');
Route::post('/thread/{thread}/reply', [ForumController::class, 'reply'])->name('thread.reply');
Route::post('/post/{post}/report', [ForumController::class, 'reportPost'])->name('post.report');
Route::get('/post/{post}/edit', [ForumController::class, 'editPostForm'])->name('post.edit');
Route::put('/post/{post}', [ForumController::class, 'updatePost'])->name('post.update');
});
Route::middleware(['auth', 'can:moderate-forum'])->group(function () {
Route::post('/thread/{thread}/lock', [ForumController::class, 'lockThread'])->name('thread.lock');
Route::post('/thread/{thread}/unlock', [ForumController::class, 'unlockThread'])->name('thread.unlock');
Route::post('/thread/{thread}/pin', [ForumController::class, 'pinThread'])->name('thread.pin');
Route::post('/thread/{thread}/unpin', [ForumController::class, 'unpinThread'])->name('thread.unpin');
});
});
Route::middleware('ensure.onboarding.complete')->get('/forum.php', function (\Illuminate\Http\Request $request) {
$threadId = (int) ($request->query('topic') ?? $request->query('tid') ?? 0);
if ($threadId < 1) {
return redirect()->route('forum.index', [], 301);
}
$thread = \App\Models\ForumThread::query()->find($threadId);
$slug = $thread?->slug ?: ('thread-' . $threadId);
return redirect()->route('forum.thread.show', ['thread' => $threadId, 'slug' => $slug], 301);
})->name('forum.legacy.redirect');
// News/Announcements listing — redirect to forum index until a dedicated page exists
Route::get('/news', function () {
return redirect()->route('forum.index', [], 301);
})->name('news.index');
Route::get('/news/{id}/{slug?}', [NewsController::class, 'show'])->where('id', '\\d+')->name('legacy.news.show');
Route::get('/categories', [CategoryController::class, 'index'])->name('legacy.categories');
Route::get('/sections', [\App\Http\Controllers\Web\SectionsController::class, 'index'])->name('sections');
// Clean SEO-friendly URL aliases
Route::get('/uploads/latest', [LatestController::class, 'index'])->name('uploads.latest');
Route::get('/uploads/daily', [DailyUploadsController::class, 'index'])->name('uploads.daily');
Route::get('/members/photos', [MembersController::class, 'photos'])->name('members.photos');
Route::get('/authors/top', [TopAuthorsController::class, 'index'])->name('authors.top');
Route::get('/comments/latest', [LatestCommentsController::class, 'index'])->name('comments.latest');
Route::get('/comments/monthly', [MonthlyCommentatorsController::class, 'index'])->name('comments.monthly');
Route::get('/downloads/today', [TodayDownloadsController::class, 'index'])->name('downloads.today');
Route::get('/category/{group}/{slug?}/{id?}', [BrowseGalleryController::class, 'legacyCategory'])->name('legacy.category');
Route::get('/browse', [BrowseGalleryController::class, 'browse'])->name('legacy.browse');
Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('legacy.featured');
Route::get('/featured-artworks', [FeaturedArtworksController::class, 'index'])->name('legacy.featured_artworks');
Route::get('/daily-uploads', [DailyUploadsController::class, 'index'])->name('legacy.daily_uploads');
Route::get('/chat', [ChatController::class, 'index'])->name('legacy.chat');
Route::post('/chat_post', [ChatController::class, 'post'])->name('legacy.chat.post');
Route::get('/browse-categories', [BrowseCategoriesController::class, 'index'])->name('browse.categories');
Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('profile.show');
Route::middleware('auth')->post('/@{username}/follow', [ProfileController::class, 'toggleFollow'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('profile.follow');
Route::middleware('auth')->post('/@{username}/comment', [ProfileController::class, 'storeComment'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('profile.comment');
Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('legacy.user.profile');
Route::get('/profile/{id}/{username?}', [ProfileController::class, 'legacyById'])
->where('id', '\\d+')
->name('legacy.profile.id');
Route::get('/profile/{username}', [ProfileController::class, 'legacyByUsername'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->name('legacy.profile');
Route::get('/top-favourites', [TopFavouritesController::class, 'index'])->name('legacy.top_favourites');
// /top-authors → 301 redirect to canonical /creators/top
Route::get('/top-authors', function () {
return redirect('/creators/top', 301);
})->name('legacy.top_authors');
Route::middleware('auth')->get('/mybuddies.php', [\App\Http\Controllers\User\MyBuddiesController::class, 'index'])->name('legacy.mybuddies.php');
Route::middleware('auth')->get('/mybuddies', [\App\Http\Controllers\User\MyBuddiesController::class, 'index'])->name('legacy.mybuddies');
Route::middleware('auth')->delete('/mybuddies/{id}', [\App\Http\Controllers\User\MyBuddiesController::class, 'destroy'])->name('legacy.mybuddies.delete');
Route::middleware('auth')->get('/buddies.php', [\App\Http\Controllers\User\BuddiesController::class, 'index'])->name('legacy.buddies.php');
Route::middleware('auth')->get('/buddies', [\App\Http\Controllers\User\BuddiesController::class, 'index'])->name('legacy.buddies');
Route::get('/favourites/{id?}/{username?}', [FavouritesController::class, 'index'])->name('legacy.favourites');
Route::post('/favourites/{userId}/delete/{artworkId}', [FavouritesController::class, 'destroy'])->name('legacy.favourites.delete');
Route::middleware('ensure.onboarding.complete')->get('/gallery/{id}/{username?}', [GalleryController::class, 'show'])->name('legacy.gallery');
Route::middleware('auth')->get('/recieved-comments', [ReceivedCommentsController::class, 'index'])->name('legacy.received_comments');
// Canonical dashboard profile route: serve legacy Nova-themed UI here so the
// visual remains identical to the old `/user` page while the canonical path
// follows the routing standard `/dashboard/profile`.
Route::middleware(['auth'])->match(['get','post'], '/dashboard/profile', [\App\Http\Controllers\Legacy\UserController::class, 'index'])->name('dashboard.profile');
// Keep legacy `/user` as a permanent redirect to the canonical dashboard path.
Route::middleware(['auth'])->match(['get','post'], '/user', function () {
return redirect()->route('dashboard.profile', [], 301);
})->name('legacy.user.redirect');
Route::get('/today-in-history', [TodayInHistoryController::class, 'index'])->name('legacy.today_in_history');
Route::get('/today-downloads', [TodayDownloadsController::class, 'index'])->name('legacy.today_downloads');
Route::get('/monthly-commentators', [MonthlyCommentatorsController::class, 'index'])->name('legacy.monthly_commentators');
Route::get('/members', [MembersController::class, 'index'])->name('legacy.members');
Route::get('/latest', [LatestController::class, 'index'])->name('legacy.latest');
Route::get('/latest-comments', [LatestCommentsController::class, 'index'])->name('legacy.latest_comments');
// /interviews → 301 redirect to canonical /stories
Route::get('/interviews', function () {
return redirect('/stories', 301);
})->name('legacy.interviews');
Route::get('/authors/top', [\App\Http\Controllers\User\TopAuthorsController::class, 'index'])->name('authors.top');
Route::middleware(['auth'])->group(function () {
Route::get('/statistics', [StatisticsController::class, 'index'])->name('legacy.statistics');
});
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware(['auth', \App\Http\Middleware\NoIndexDashboard::class])->prefix('dashboard')->name('dashboard.')->group(function () {
Route::get('/artworks', [DashboardArtworkController::class, 'index'])->name('artworks.index');
Route::get('/artworks/{id}/edit', [DashboardArtworkController::class, 'edit'])->whereNumber('id')->name('artworks.edit');
Route::put('/artworks/{id}', [DashboardArtworkController::class, 'update'])->whereNumber('id')->name('artworks.update');
Route::delete('/artworks/{id}', [DashboardArtworkController::class, 'destroy'])->whereNumber('id')->name('artworks.destroy');
// Favorites (user's own favourites)
Route::get('/favorites', [\App\Http\Controllers\Dashboard\FavoriteController::class, 'index'])->name('favorites');
Route::delete('/favorites/{artwork}', [\App\Http\Controllers\Dashboard\FavoriteController::class, 'destroy'])->name('favorites.destroy');
// Followers / Following / Comments (dashboard)
Route::get('/followers', [\App\Http\Controllers\Dashboard\FollowerController::class, 'index'])->name('followers');
Route::get('/following', [\App\Http\Controllers\Dashboard\FollowingController::class, 'index'])->name('following');
Route::get('/comments', [\App\Http\Controllers\Dashboard\CommentController::class, 'index'])->name('comments');
// Gallery (user uploads)
Route::get('/gallery', [\App\Http\Controllers\Dashboard\DashboardGalleryController::class, 'index'])->name('gallery');
// Awards received on the user's own artworks
Route::get('/awards', [\App\Http\Controllers\Dashboard\DashboardAwardsController::class, 'index'])->name('awards');
});
// ── Studio Pro (Creator Artwork Manager) ────────────────────────────────────
use App\Http\Controllers\Studio\StudioController;
Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->name('studio.')->group(function () {
Route::get('/', [StudioController::class, 'index'])->name('index');
Route::get('/artworks', [StudioController::class, 'artworks'])->name('artworks');
Route::get('/artworks/drafts', [StudioController::class, 'drafts'])->name('drafts');
Route::get('/artworks/archived', [StudioController::class, 'archived'])->name('archived');
Route::get('/artworks/{id}/edit', [StudioController::class, 'edit'])->whereNumber('id')->name('artworks.edit');
Route::get('/artworks/{id}/analytics', [StudioController::class, 'analytics'])->whereNumber('id')->name('artworks.analytics');
Route::get('/analytics', [StudioController::class, 'analyticsOverview'])->name('analytics');
});
Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])->group(function () {
// Redirect legacy `/profile` edit path to canonical dashboard profile route.
Route::get('/profile', function () {
return redirect()->route('dashboard.profile', [], 301);
})->name('legacy.profile.redirect');
// Backwards-compatible settings path used by some layouts/links
Route::get('/settings', [ProfileController::class, 'edit'])->name('settings');
Route::match(['post','put','patch'], '/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Password change endpoint (accepts POST or PUT from legacy and new forms)
Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password');
// Avatar upload (backend only) - processes and stores avatars
Route::post('/avatar/upload', [AvatarController::class, 'upload'])->middleware('throttle:20,1')->name('avatar.upload');
});
Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
Route::get('/upload', function () {
$contentTypes = ContentType::with(['rootCategories.children'])->get()->map(function ($ct) {
return [
'id' => $ct->id,
'name' => $ct->name,
'categories' => $ct->rootCategories->map(function ($c) {
return [
'id' => $c->id,
'name' => $c->name,
'children' => $c->children->map(function ($ch) {
return ['id' => $ch->id, 'name' => $ch->name];
})->values()->all(),
];
})->values()->all(),
];
})->values()->all();
return Inertia::render('Upload/Index', [
'draftId' => null,
'content_types' => $contentTypes,
'suggested_tags' => [],
'filesCdnUrl' => config('cdn.files_url'),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'feature_flags' => [
'uploads_v2' => (bool) config('features.uploads_v2', false),
],
]);
})->name('upload');
Route::get('/upload/draft/{id}', function (string $id) {
$contentTypes = ContentType::with(['rootCategories.children'])->get()->map(function ($ct) {
return [
'id' => $ct->id,
'name' => $ct->name,
'categories' => $ct->rootCategories->map(function ($c) {
return [
'id' => $c->id,
'name' => $c->name,
'children' => $c->children->map(function ($ch) {
return ['id' => $ch->id, 'name' => $ch->name];
})->values()->all(),
];
})->values()->all(),
];
})->values()->all();
return Inertia::render('Upload/Index', [
'draftId' => $id,
'content_types' => $contentTypes,
'suggested_tags' => [],
'filesCdnUrl' => config('cdn.files_url'),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'feature_flags' => [
'uploads_v2' => (bool) config('features.uploads_v2', false),
],
]);
})->whereUuid('id')->name('upload.draft');
});
require __DIR__.'/auth.php';
Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index'])
->name('search');
Route::get('/tag/{tag:slug}', [\App\Http\Controllers\Web\TagController::class, 'show'])
->where('tag', '[a-z0-9\-]+')
->name('tags.show');
Route::view('/blank', 'blank')->name('blank');
// Bind the artwork route parameter to a model if it exists, otherwise return null
use App\Models\Artwork;
Route::bind('artwork', function ($value) {
return Artwork::where('slug', $value)->first();
});
// Universal content router: handles content-type roots, nested categories and artwork slugs.
// Keep the explicit /photography route above (if present) so the legacy controller can continue
// to serve photography's root page. This catch-all route delegates to a controller that
// will forward to the appropriate existing controller (artwork or category handlers).
// Provide a named route alias for legacy artwork URL generation used in tests.
Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [\App\Http\Controllers\Web\BrowseGalleryController::class, 'showArtwork'])
->where('contentTypeSlug', 'photography|wallpapers|skins|other')
->where('categoryPath', '[^/]+(?:/[^/]+)*')
->name('artworks.show');
Route::get('/{contentTypeSlug}/{path?}', [\App\Http\Controllers\Web\BrowseGalleryController::class, 'content'])
->where('contentTypeSlug', 'photography|wallpapers|skins|other')
->where('path', '.*')
->name('content.route');
Route::middleware(['auth'])->group(function () {
Route::get('/manage', [ManageController::class, 'index'])->name('manage');
Route::get('/manage/edit/{id}', [ManageController::class, 'edit'])->name('manage.edit');
Route::post('/manage/update/{id}', [ManageController::class, 'update'])->name('manage.update');
Route::post('/manage/delete/{id}', [ManageController::class, 'destroy'])->name('manage.destroy');
});
// Admin routes for artworks (separated from public routes)
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
Route::get('uploads/moderation', function () {
return Inertia::render('Admin/UploadQueue');
})->middleware('admin.moderation')->name('uploads.moderation');
Route::get('usernames/moderation', function () {
return Inertia::render('Admin/UsernameQueue');
})->middleware('admin.moderation')->name('usernames.moderation');
Route::resource('artworks', \App\Http\Controllers\Admin\ArtworkController::class)->except(['show']);
Route::get('reports', function () {
return view('admin.reports.queue');
})->middleware('admin.moderation')->name('reports.queue');
});
Route::middleware(['auth', 'ensure.onboarding.complete'])
->get('/messages/attachments/{id}', [\App\Http\Controllers\Api\Messaging\AttachmentController::class, 'show'])
->whereNumber('id')
->name('messages.attachments.show');
// ── Messages ──────────────────────────────────────────────────────────────────
Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->name('messages.')->group(function () {
Route::get('/', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'index'])->name('index');
Route::get('/{id}', [\App\Http\Controllers\Messaging\MessagesPageController::class, 'show'])->whereNumber('id')->name('show');
});
// ── Community Activity Feed ───────────────────────────────────────────────────
Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index'])
->name('community.activity');
// ── Posts / Following Feed ────────────────────────────────────────────────────
// /feed/following Inertia page for the ranked, diversified following feed
Route::middleware(['auth', 'ensure.onboarding.complete'])
->get('/feed/following', [\App\Http\Controllers\Web\Posts\FollowingFeedController::class, 'index'])
->name('feed.following');
// ── Feed 2.0: Trending Feed ───────────────────────────────────────────────────
Route::get('/feed/trending', [\App\Http\Controllers\Web\Posts\TrendingFeedController::class, 'index'])
->name('feed.trending');
// ── Feed 2.0: Hashtag Feed ────────────────────────────────────────────────────
Route::get('/tags/{tag}', [\App\Http\Controllers\Web\Posts\HashtagFeedController::class, 'index'])
->where('tag', '[A-Za-z][A-Za-z0-9_]{1,63}')
->name('feed.hashtag');
// ── Feed 2.0: Saved Posts ─────────────────────────────────────────────────────
Route::middleware(['auth'])
->get('/feed/saved', [\App\Http\Controllers\Web\Posts\SavedFeedController::class, 'index'])
->name('feed.saved');
// ── Feed 2.0: Post Search ─────────────────────────────────────────────────────
Route::get('/feed/search', [\App\Http\Controllers\Web\Posts\SearchFeedController::class, 'index'])
->name('feed.search');