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

@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\ActivityEvent;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* PublishScheduledArtworksCommand
*
* Runs every minute (via Kernel schedule).
* Finds artworks with:
* - artwork_status = 'scheduled'
* - publish_at <= now() (UTC)
* - is_approved = true (respect moderation gate)
*
* Publishes each one:
* - sets is_public = true
* - sets published_at = now()
* - sets artwork_status = 'published'
* - dispatches Meilisearch reindex (via Scout)
* - records activity event
*
* Safe to run concurrently (DB row lock prevents double-publish).
*/
class PublishScheduledArtworksCommand extends Command
{
protected $signature = 'artworks:publish-scheduled
{--dry-run : List candidate artworks without publishing}
{--limit=100 : Max artworks to process per run}';
protected $description = 'Publish scheduled artworks whose publish_at datetime has passed.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = (int) $this->option('limit');
$now = now()->utc();
$candidates = Artwork::query()
->where('artwork_status', 'scheduled')
->where('publish_at', '<=', $now)
->where('is_approved', true)
->orderBy('publish_at')
->limit($limit)
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
if ($candidates->isEmpty()) {
$this->line('No scheduled artworks due for publishing.');
return self::SUCCESS;
}
$this->info("Found {$candidates->count()} artwork(s) to publish." . ($dryRun ? ' [DRY RUN]' : ''));
$published = 0;
$errors = 0;
foreach ($candidates as $candidate) {
if ($dryRun) {
$this->line(" [dry-run] Would publish artwork #{$candidate->id}: \"{$candidate->title}\"");
continue;
}
try {
DB::transaction(function () use ($candidate, $now, &$published) {
// Re-fetch with lock to avoid double-publish in concurrent runs
$artwork = Artwork::query()
->lockForUpdate()
->where('id', $candidate->id)
->where('artwork_status', 'scheduled')
->first();
if (! $artwork) {
// Already published or status changed skip
return;
}
$artwork->is_public = true;
$artwork->published_at = $now;
$artwork->artwork_status = 'published';
$artwork->save();
// Trigger Meilisearch reindex via Scout (if searchable trait present)
if (method_exists($artwork, 'searchable')) {
try {
$artwork->searchable();
} catch (\Throwable $e) {
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
}
}
// Record activity event
try {
ActivityEvent::record(
actorId: (int) $artwork->user_id,
type: ActivityEvent::TYPE_UPLOAD,
targetType: ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
);
} catch (\Throwable) {}
$published++;
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
});
} catch (\Throwable $e) {
$errors++;
Log::error("PublishScheduledArtworksCommand: failed to publish artwork #{$candidate->id}: {$e->getMessage()}");
$this->error(" Failed to publish #{$candidate->id}: {$e->getMessage()}");
}
}
if (! $dryRun) {
$this->info("Done. Published: {$published}, Errors: {$errors}.");
}
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Publishes posts whose publish_at timestamp has passed.
* Scheduled every minute via console/kernel.
*/
class PublishScheduledPostsCommand extends Command
{
protected $signature = 'posts:publish-scheduled';
protected $description = 'Publish all scheduled posts whose publish_at time has been reached.';
public function handle(): int
{
$count = Post::where('status', Post::STATUS_SCHEDULED)
->where('publish_at', '<=', now())
->count();
if ($count === 0) {
$this->line('No scheduled posts to publish.');
return self::SUCCESS;
}
$published = 0;
Post::where('status', Post::STATUS_SCHEDULED)
->where('publish_at', '<=', now())
->chunkById(100, function ($posts) use (&$published) {
foreach ($posts as $post) {
DB::transaction(function () use ($post) {
$post->update(['status' => Post::STATUS_PUBLISHED]);
});
$published++;
}
});
$this->info("Published {$published} scheduled post(s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Illuminate\Console\Command;
class ReindexRecentPublishedArtworksCommand extends Command
{
protected $signature = 'artworks:search-reindex-recent
{--hours=72 : Reindex artworks published in the last N hours}
{--limit=1000 : Maximum artworks to process in this run}
{--id=* : Specific artwork IDs to reindex (overrides --hours window)}
{--dry-run : Show candidates without dispatching index jobs}';
protected $description = 'Reindex recently published public artworks to recover missed search indexing.';
public function handle(): int
{
$hours = max(1, (int) $this->option('hours'));
$limit = max(1, (int) $this->option('limit'));
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
$dryRun = (bool) $this->option('dry-run');
$since = now()->subHours($hours);
$query = Artwork::query()
->whereNull('deleted_at')
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at');
if ($ids !== []) {
$query->whereIn('id', $ids)->orderBy('id');
} else {
$query->where('published_at', '>=', $since)
->orderByDesc('published_at');
}
$candidates = $query->limit($limit)->get(['id', 'title', 'slug', 'published_at']);
if ($candidates->isEmpty()) {
if ($ids !== []) {
$this->line('No matching published artworks found for the provided --id values.');
} else {
$this->line("No published artworks found in the last {$hours} hour(s).");
}
return self::SUCCESS;
}
if ($ids !== []) {
$this->info('Found ' . $candidates->count() . ' target artwork(s) by --id.' . ($dryRun ? ' [DRY RUN]' : ''));
} else {
$this->info("Found {$candidates->count()} artwork(s) published in the last {$hours} hour(s)." . ($dryRun ? ' [DRY RUN]' : ''));
}
foreach ($candidates as $artwork) {
if ($dryRun) {
$this->line(" [dry-run] Would reindex #{$artwork->id} ({$artwork->slug})");
continue;
}
IndexArtworkJob::dispatchSync((int) $artwork->id);
$this->line(" Reindexed #{$artwork->id} ({$artwork->slug})");
}
if (! $dryRun) {
$this->info('Done. Recent published artworks were reindexed.');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Posts\PostTrendingService;
use Illuminate\Console\Command;
/**
* Warms the post trending cache so requests are fast.
* Scheduled every 2 minutes to match the cache TTL.
*/
class WarmPostTrendingCommand extends Command
{
protected $signature = 'posts:warm-trending';
protected $description = 'Refresh the post trending feed cache.';
public function __construct(private PostTrendingService $trending)
{
parent::__construct();
}
public function handle(): int
{
$ids = $this->trending->refresh();
$this->info('Trending feed cache refreshed. ' . count($ids) . ' post(s) ranked.');
return self::SUCCESS;
}
}

View File

@@ -19,6 +19,7 @@ use App\Console\Commands\RecalculateHeatCommand;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
use App\Console\Commands\PublishScheduledArtworksCommand;
class Kernel extends ConsoleKernel
{
@@ -35,6 +36,7 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\AvatarsMigrate::class,
\App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class,
BackfillArtworkEmbeddingsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class,
@@ -54,6 +56,13 @@ class Kernel extends ConsoleKernel
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
$schedule->command('uploads:cleanup')->dailyAt('03:00');
// Publish artworks whose scheduled publish_at has passed
$schedule->command('artworks:publish-scheduled')
->everyMinute()
->name('publish-scheduled-artworks')
->withoutOverlapping(2) // prevent overlap up to 2 minutes
->runInBackground();
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)