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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user