Post Analytics
+ +{item.value}
+{item.label}
+diff --git a/app/Console/Commands/PublishScheduledArtworksCommand.php b/app/Console/Commands/PublishScheduledArtworksCommand.php new file mode 100644 index 00000000..37e1cee3 --- /dev/null +++ b/app/Console/Commands/PublishScheduledArtworksCommand.php @@ -0,0 +1,122 @@ +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; + } +} diff --git a/app/Console/Commands/PublishScheduledPostsCommand.php b/app/Console/Commands/PublishScheduledPostsCommand.php new file mode 100644 index 00000000..fa427bd7 --- /dev/null +++ b/app/Console/Commands/PublishScheduledPostsCommand.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/app/Console/Commands/ReindexRecentPublishedArtworksCommand.php b/app/Console/Commands/ReindexRecentPublishedArtworksCommand.php new file mode 100644 index 00000000..460e7db1 --- /dev/null +++ b/app/Console/Commands/ReindexRecentPublishedArtworksCommand.php @@ -0,0 +1,76 @@ +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; + } +} diff --git a/app/Console/Commands/WarmPostTrendingCommand.php b/app/Console/Commands/WarmPostTrendingCommand.php new file mode 100644 index 00000000..40d8b788 --- /dev/null +++ b/app/Console/Commands/WarmPostTrendingCommand.php @@ -0,0 +1,28 @@ +trending->refresh(); + $this->info('Trending feed cache refreshed. ' . count($ids) . ' post(s) ranked.'); + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index a2f8e68d..0769b818 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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) diff --git a/app/Events/Posts/ArtworkShared.php b/app/Events/Posts/ArtworkShared.php new file mode 100644 index 00000000..07f549ee --- /dev/null +++ b/app/Events/Posts/ArtworkShared.php @@ -0,0 +1,20 @@ + + private const USER_AGENT = 'Skinbase-LinkPreview/1.0 (+https://skinbase.org)'; + + /** Blocked IP ranges (SSRF protection). */ + private const BLOCKED_CIDRS = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '240.0.0.0/4', + '::1/128', + 'fc00::/7', + 'fe80::/10', + ]; + + public function __invoke(Request $request): JsonResponse + { + $request->validate([ + 'url' => ['required', 'string', 'max:2048'], + ]); + + $rawUrl = trim((string) $request->input('url')); + + // Must be http(s) + if (! preg_match('#^https?://#i', $rawUrl)) { + return response()->json(['error' => 'Invalid URL scheme.'], 422); + } + + $parsed = parse_url($rawUrl); + $host = $parsed['host'] ?? ''; + + if (empty($host)) { + return response()->json(['error' => 'Invalid URL.'], 422); + } + + // Resolve hostname and block private/loopback IPs (SSRF protection) + $resolved = gethostbyname($host); + if ($this->isBlockedIp($resolved)) { + return response()->json(['error' => 'URL not allowed.'], 422); + } + + try { + $client = new Client([ + 'timeout' => self::TIMEOUT, + 'connect_timeout' => 4, + 'allow_redirects' => ['max' => 5, 'strict' => false], + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + 'Accept' => 'text/html,application/xhtml+xml', + ], + 'verify' => true, + ]); + + $response = $client->get($rawUrl); + $status = $response->getStatusCode(); + + if ($status < 200 || $status >= 400) { + return response()->json(['error' => 'Could not fetch URL.'], 422); + } + + // Read up to MAX_BYTES – we only need the HTML
+ $body = ''; + $stream = $response->getBody(); + while (! $stream->eof() && strlen($body) < self::MAX_BYTES) { + $body .= $stream->read(4096); + } + $stream->close(); + + } catch (TransferException $e) { + return response()->json(['error' => 'Could not reach URL.'], 422); + } + + $preview = $this->extractMeta($body, $rawUrl); + + return response()->json($preview); + } + + /** Extract OG / Twitter / fallback meta tags. */ + private function extractMeta(string $html, string $originalUrl): array + { + // Limit to roughly the block for speed + $head = substr($html, 0, 50_000); + + $og = []; + + // OG / Twitter meta tags + preg_match_all( + '/]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*>/i', + $head, + $m1, + PREG_SET_ORDER, + ); + preg_match_all( + '/]*content\s*=\s*["\']([^"\']*)["\'][^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*>/i', + $head, + $m2, + PREG_SET_ORDER, + ); + + $allMeta = array_merge( + array_map(fn ($r) => ['key' => strtolower($r[1]), 'value' => $r[2]], $m1), + array_map(fn ($r) => ['key' => strtolower($r[2]), 'value' => $r[1]], $m2), + ); + + $map = []; + foreach ($allMeta as $entry) { + $map[$entry['key']] ??= $entry['value']; + } + + // Canonical URL + $canonical = $originalUrl; + if (preg_match('/]+rel\s*=\s*["\']canonical["\'][^>]+href\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $head, $mc)) { + $canonical = $mc[1]; + } elseif (preg_match('/]+href\s*=\s*["\']([^"\']+)["\'][^>]+rel\s*=\s*["\']canonical["\'][^>]*>/i', $head, $mc)) { + $canonical = $mc[1]; + } + + // Title + $title = $map['og:title'] + ?? $map['twitter:title'] + ?? null; + if (! $title && preg_match('/+ Follow some creators to see their posts here. Discover amazing artwork on{' '} + Trending. +
+Posts from creators you follow
++ {totalPosts.toLocaleString()} post{totalPosts !== 1 ? 's' : ''} +
+ )} ++ No posts tagged #{tag} yet. Be the first! +
+Posts you've bookmarked
++ Bookmark posts to read later. Look for the{' '} + icon on any post. +
+ + Browse trending posts → + ++ Search by keywords, hashtags, or phrases +
++ Type at least 2 characters to search posts +
++ Nothing matched “{query}”. + Try different keywords or a hashtag. +
++ {meta.total.toLocaleString()} result{meta.total !== 1 ? 's' : ''} for{' '} + “{query}” +
+ )} + + {/* Post cards */} + {results.map((post) => ( +Most engaging posts right now
+Check back soon — posts are ranked by engagement.
+Forum
+No threads in this section yet.
+ {isAuthenticated && slug && ( + + Be the first to start a discussion → + + )} +Edit
+Community
+Browse forum sections and join the conversation.
+No forum categories available yet.
+New thread
+{number(replyCount)} {replyCount === 1 ? 'reply' : 'replies'}
+ +{artwork.title}
+ e.stopPropagation()} + className="text-xs text-slate-400 hover:text-sky-400 transition-colors mt-0.5 truncate" + > + + by {artwork.author.name || `@${artwork.author.username}`} + + Artwork +{bio}
+ )} + ++ {f.name || f.uname || f.username} +
+@{f.username}
++ {u.name || u.username} +
+@{u.username}
++ {preview.site_name} +
+ )} + {preview.title && ( ++ {preview.title} +
+ )} + {preview.description && ( ++ {preview.description} +
+ )} ++ {domain} +
+{item.value}
+{item.label}
++ Sign in to comment. +
+ )} + + {error &&{error}
} +{body.length}/2000
+ )} + + {error && ( +{error}
+ )} + + )} + + + {/* Share artwork modal */} +No artworks found.
+ )} +{selected.title}
++ by {selected.user?.name ?? selected.author?.name ?? selected.author_name ?? 'Unknown'} +
++ + {error} +
+ )} +No users found for "{query}"
+ )} + {results.length === 0 && query.length < 2 && ( +Type a name or @username to search
+ )} + {results.map((u) => { + const checked = isSelected(u) + return ( + + ) + })} +- It has been published and is now visible to the community. +
+ {wasScheduled + ? scheduledAt + ? `Will publish on ${new Intl.DateTimeFormat('en-GB', { timeZone: userTimezone, weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(scheduledAt))}` + : 'Your artwork is scheduled for future publishing.' + : 'It has been published and is now visible to the community.'}
-Readiness checks
diff --git a/resources/js/components/uploads/UploadWizard.jsx b/resources/js/components/uploads/UploadWizard.jsx index 59ac5600..3267842d 100644 --- a/resources/js/components/uploads/UploadWizard.jsx +++ b/resources/js/components/uploads/UploadWizard.jsx @@ -606,7 +606,7 @@ export default function UploadWizard({+ Artworks you saved, displayed in the same gallery layout as Browse. +
+ @if($artworks->total() > 0) +You have no favourites yet.
- @else -{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}
- @endif - @if (isset($category) && auth()->check()) -| Thread | -Posts | -By | -Last Update | -
|---|---|---|---|
|
- {{ $title }}
- @if (!empty($sub->discuss))
- {!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}
- @endif
- |
- {{ $sub->num_posts ?? 0 }} | -{{ $sub->uname ?? 'Unknown' }} | -- @if (!empty($sub->last_update)) - {{ Carbon::parse($sub->last_update)->format('d.m.Y H:i') }} - @elseif (!empty($sub->post_date)) - {{ Carbon::parse($sub->post_date)->format('d.m.Y H:i') }} - @else - - - @endif - | -
| No threads in this section yet. | -|||
Browse forum sections and latest activity.
-{{ __('Auto-post new uploads') }}
+{{ __('Automatically create a feed post when you publish new artwork.') }}
++ @endif + + +@endpush + +@section('content') + @inertia +@endsection diff --git a/resources/views/web/discover/_nav.blade.php b/resources/views/web/discover/_nav.blade.php new file mode 100644 index 00000000..cf157821 --- /dev/null +++ b/resources/views/web/discover/_nav.blade.php @@ -0,0 +1,38 @@ +{{-- + Discover section-switcher pills. + + Expected variable: $section (string) — the active section slug, e.g. 'trending', 'for-you' + Expected variable: $isAuthenticated (bool, optional) — whether the user is logged in +--}} + +@php + $active = $section ?? ''; + $isAuth = $isAuthenticated ?? auth()->check(); + + $sections = collect([ + 'for-you' => ['label' => 'For You', 'icon' => 'fa-wand-magic-sparkles', 'auth' => true, 'activeClass' => 'bg-yellow-500/20 text-yellow-300 border border-yellow-400/20'], + 'following' => ['label' => 'Following', 'icon' => 'fa-user-group', 'auth' => true, 'activeClass' => 'bg-sky-600 text-white'], + 'trending' => ['label' => 'Trending', 'icon' => 'fa-fire', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'], + 'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'], + 'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'], + 'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'], + 'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'], + 'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'], + ]); +@endphp + +
Cool piece!
') + ->and($post->targets()->where('target_type', 'artwork')->where('target_id', $artwork->id)->exists())->toBeTrue(); +}); + +test('shareArtwork throws when sharing same artwork within 24 hours', function () { + $user = User::factory()->create(); + $artwork = postTestArtwork(['user_id' => $user->id]); + + $service = app(PostShareService::class); + $service->shareArtwork($user, $artwork, '', 'public'); + + expect(fn () => $service->shareArtwork($user, $artwork, 'Again', 'public')) + ->toThrow(ValidationException::class); +}); + +test('shareArtwork rejects private or unapproved artwork', function () { + $user = User::factory()->create(); + $private = postTestArtwork(['user_id' => $user->id, 'is_public' => false, 'is_approved' => true]); + + $service = app(PostShareService::class); + expect(fn () => $service->shareArtwork($user, $private, '', 'public')) + ->toThrow(ValidationException::class); +}); + +// ── Profile feed API ─────────────────────────────────────────────────────────── + +test('GET /api/posts/profile/{username} returns paginated public posts', function () { + $author = User::factory()->create(); + Post::factory()->count(3)->create(['user_id' => $author->id, 'visibility' => 'public']); + Post::factory()->create(['user_id' => $author->id, 'visibility' => 'private']); + + $response = $this->getJson("/api/posts/profile/{$author->username}"); + $response->assertOk()->assertJsonCount(3, 'data'); +}); + +test('GET /api/posts/profile/{username} hides followers-only posts from strangers', function () { + $author = User::factory()->create(); + $viewer = User::factory()->create(); + Post::factory()->count(2)->create(['user_id' => $author->id, 'visibility' => 'followers']); + Post::factory()->count(1)->create(['user_id' => $author->id, 'visibility' => 'public']); + + $response = $this->actingAs($viewer)->getJson("/api/posts/profile/{$author->username}"); + $response->assertOk()->assertJsonCount(1, 'data'); +}); + +// ── Following feed API ───────────────────────────────────────────────────────── + +test('GET /api/posts/following requires authentication', function () { + $this->getJson('/api/posts/following')->assertUnauthorized(); +}); + +test('GET /api/posts/following returns posts from followed users only', function () { + $viewer = User::factory()->create(); + $followed = User::factory()->create(); + $stranger = User::factory()->create(); + + DB::table('user_followers')->insert([ + 'user_id' => $followed->id, + 'follower_id' => $viewer->id, + 'created_at' => now(), + ]); + + Post::factory()->create(['user_id' => $followed->id, 'visibility' => 'public']); + Post::factory()->create(['user_id' => $stranger->id, 'visibility' => 'public']); + + $response = $this->actingAs($viewer)->getJson('/api/posts/following'); + $response->assertOk(); + + $ids = collect($response->json('data'))->pluck('author.id'); + expect($ids)->each->toBe($followed->id); +}); + +// ── Reactions ───────────────────────────────────────────────────────────────── + +test('POST /api/posts/{id}/reactions adds reaction and increments counter', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public']); + + $this->actingAs($user) + ->postJson("/api/posts/{$post->id}/reactions", ['reaction' => 'like']) + ->assertStatus(201); + + expect($post->fresh()->reactions_count)->toBe(1); + expect(PostReaction::where(['post_id' => $post->id, 'user_id' => $user->id])->exists())->toBeTrue(); +}); + +test('DELETE /api/posts/{id}/reactions/{reaction} removes reaction and decrements counter', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public', 'reactions_count' => 1]); + PostReaction::create(['post_id' => $post->id, 'user_id' => $user->id, 'reaction' => 'like']); + + $this->actingAs($user) + ->deleteJson("/api/posts/{$post->id}/reactions/like") + ->assertOk(); + + expect($post->fresh()->reactions_count)->toBe(0); + expect(PostReaction::where(['post_id' => $post->id, 'user_id' => $user->id])->exists())->toBeFalse(); +}); + +// ── Comments ────────────────────────────────────────────────────────────────── + +test('POST /api/posts/{id}/comments creates comment and increments counter', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public']); + + $this->actingAs($user) + ->postJson("/api/posts/{$post->id}/comments", ['body' => 'Nice post!']) + ->assertCreated() + ->assertJsonPath('comment.body', 'Nice post!
'); + + expect($post->fresh()->comments_count)->toBe(1); +}); + +test('GET /api/posts/{id}/comments is publicly accessible', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $user->id, 'visibility' => 'public']); + + $this->getJson("/api/posts/{$post->id}/comments") + ->assertOk() + ->assertJsonStructure(['data', 'meta']); +}); + +// ── Diversity pass ───────────────────────────────────────────────────────────── + +test('PostFeedService diversity pass limits consecutive posts from same author', function () { + $service = app(PostFeedService::class); + + $userA = User::factory()->create(); + $userB = User::factory()->create(); + + // 8 posts from A then 2 from B + $posts = collect(); + for ($i = 0; $i < 8; $i++) { + $posts->push(Post::factory()->make(['user_id' => $userA->id])); + } + for ($i = 0; $i < 2; $i++) { + $posts->push(Post::factory()->make(['user_id' => $userB->id])); + } + + $diversified = $service->applyDiversityPass($posts); + + // After the 5th consecutive post from A, subsequent A posts should be deferred + $runLength = 0; + $maxRun = 0; + $lastId = null; + foreach ($diversified as $post) { + if ($post->user_id === $lastId) { + $runLength++; + } else { + $runLength = 1; + $lastId = $post->user_id; + } + $maxRun = max($maxRun, $runLength); + } + + expect($maxRun)->toBeLessThanOrEqual(5); +}); diff --git a/vite.config.js b/vite.config.js index f515624a..c1add4f6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -20,6 +20,9 @@ export default defineConfig({ 'resources/js/Pages/Home/HomePage.jsx', 'resources/js/Pages/Community/LatestCommentsPage.jsx', 'resources/js/Pages/Messages/Index.jsx', + 'resources/js/profile.jsx', + 'resources/js/feed.jsx', + 'resources/js/entry-forum.jsx', ], // Only watch Blade templates & routes for full-reload triggers // (instead of `true` which watches the entire project tree)