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
This commit is contained in:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View File

@@ -81,6 +81,7 @@ final class UploadController extends Controller
$user = $request->user();
$sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id');
$originalFileName = $request->validated('file_name');
$session = $sessions->getOrFail($sessionId);
@@ -94,6 +95,14 @@ final class UploadController extends Controller
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($pipeline->originalHashExists($validated->hash)) {
return response()->json([
'message' => 'Duplicate upload is not allowed. This file already exists.',
'reason' => 'duplicate_hash',
'hash' => $validated->hash,
], Response::HTTP_CONFLICT);
}
$scan = $pipeline->scan($sessionId);
if (! $scan->ok) {
return response()->json([
@@ -103,13 +112,13 @@ final class UploadController extends Controller
}
try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName) {
if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit();
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null)->afterCommit();
return 'queued';
}
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId);
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null);
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
@@ -476,10 +485,34 @@ final class UploadController extends Controller
$user = $request->user();
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
// Scheduled-publishing fields
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
]);
$mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public';
// Resolve the UTC publish_at datetime for schedule mode
$publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try {
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
// Must be at least 1 minute in the future (server-side guard)
if ($publishAt->lte(now()->addMinute())) {
return response()->json([
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
} catch (\Throwable) {
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
if (ctype_digit($id)) {
$artworkId = (int) $id;
$artwork = Artwork::query()->find($artworkId);
@@ -512,12 +545,58 @@ final class UploadController extends Controller
if (array_key_exists('description', $validated)) {
$artwork->description = $validated['description'];
}
$artwork->slug = $slug;
$artwork->is_public = true;
$artwork->is_approved = true;
$artwork->published_at = now();
$artwork->slug = $slug;
$artwork->artwork_timezone = $validated['timezone'] ?? null;
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
$artwork->is_public = false;
$artwork->is_approved = true;
$artwork->publish_at = $publishAt;
$artwork->artwork_status = 'scheduled';
$artwork->published_at = null;
$artwork->save();
try {
$artwork->unsearchable();
} catch (\Throwable $e) {
Log::warning('Failed to remove scheduled artwork from search index', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'scheduled',
'slug' => (string) $artwork->slug,
'publish_at' => $publishAt->toISOString(),
'published_at' => null,
], Response::HTTP_OK);
}
// Publish immediately
$artwork->is_public = ($visibility !== 'private');
$artwork->is_approved = true;
$artwork->published_at = now();
$artwork->artwork_status = 'published';
$artwork->publish_at = null;
$artwork->save();
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
Log::warning('Failed to sync artwork search index after publish', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
// Record upload activity event
try {
\App\Models\ActivityEvent::record(
@@ -529,10 +608,10 @@ final class UploadController extends Controller
} catch (\Throwable) {}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'published_at' => optional($artwork->published_at)->toISOString(),
], Response::HTTP_OK);
}
@@ -541,11 +620,11 @@ final class UploadController extends Controller
$upload = $publishService->publish($id, $user);
return response()->json([
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) ($upload->final_path ?? ''),
'final_path' => (string) ($upload->final_path ?? ''),
], Response::HTTP_OK);
} catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);