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('/]*>([^<]+)<\/title>/i', $head, $mt)) { + $title = trim(html_entity_decode($mt[1])); + } + + // Description + $description = $map['og:description'] + ?? $map['twitter:description'] + ?? $map['description'] + ?? null; + + // Image + $image = $map['og:image'] + ?? $map['twitter:image'] + ?? $map['twitter:image:src'] + ?? null; + + // Resolve relative image URL + if ($image && ! preg_match('#^https?://#i', $image)) { + $parsed = parse_url($originalUrl); + $base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? ''); + $image = $base . '/' . ltrim($image, '/'); + } + + // Site name + $siteName = $map['og:site_name'] ?? parse_url($originalUrl, PHP_URL_HOST) ?? null; + + return [ + 'url' => $canonical, + 'title' => $title ? html_entity_decode($title) : null, + 'description' => $description ? html_entity_decode($description) : null, + 'image' => $image, + 'site_name' => $siteName, + ]; + } + + private function isBlockedIp(string $ip): bool + { + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + return true; // could not resolve + } + foreach (self::BLOCKED_CIDRS as $cidr) { + if ($this->ipInCidr($ip, $cidr)) { + return true; + } + } + return false; + } + + private function ipInCidr(string $ip, string $cidr): bool + { + [$subnet, $bits] = explode('/', $cidr) + [1 => 32]; + + // IPv6 + if (str_contains($cidr, ':')) { + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return false; + } + $ipBin = inet_pton($ip); + $subnetBin = inet_pton($subnet); + if ($ipBin === false || $subnetBin === false) { + return false; + } + $bits = (int) $bits; + $mask = str_repeat("\xff", (int) ($bits / 8)); + $remain = $bits % 8; + if ($remain) { + $mask .= chr(0xff << (8 - $remain)); + } + $mask = str_pad($mask, strlen($subnetBin), "\x00"); + return ($ipBin & $mask) === ($subnetBin & $mask); + } + + // IPv4 + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + $ipLong = ip2long($ip); + $subnetLong = ip2long($subnet); + $maskLong = $bits == 32 ? -1 : ~((1 << (32 - (int) $bits)) - 1); + return ($ipLong & $maskLong) === ($subnetLong & $maskLong); + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php new file mode 100644 index 00000000..2c9f5fab --- /dev/null +++ b/app/Http/Controllers/Api/NotificationController.php @@ -0,0 +1,61 @@ +user(); + $page = max(1, (int) $request->query('page', 1)); + + $notifications = $user->notifications() + ->latest() + ->limit(200) // aggregate from last 200 raw notifs + ->get(); + + $digested = $this->digest->aggregate($notifications); + + // Simple manual pagination on the digested array + $perPage = 20; + $total = count($digested); + $sliced = array_slice($digested, ($page - 1) * $perPage, $perPage); + $unread = $user->unreadNotifications()->count(); + + return response()->json([ + 'data' => array_values($sliced), + 'unread_count' => $unread, + 'meta' => [ + 'total' => $total, + 'current_page' => $page, + 'last_page' => (int) ceil($total / $perPage) ?: 1, + 'per_page' => $perPage, + ], + ]); + } + + public function readAll(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + return response()->json(['message' => 'All notifications marked as read.']); + } + + public function markRead(Request $request, string $id): JsonResponse + { + $notif = $request->user()->notifications()->findOrFail($id); + $notif->markAsRead(); + return response()->json(['message' => 'Notification marked as read.']); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostAnalyticsController.php b/app/Http/Controllers/Api/Posts/PostAnalyticsController.php new file mode 100644 index 00000000..41894ba8 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostAnalyticsController.php @@ -0,0 +1,44 @@ +findOrFail($id); + + // Session key: authenticated user ID or hashed IP + $sessionKey = $request->user() + ? 'u:' . $request->user()->id + : 'ip:' . md5($request->ip()); + + $counted = $this->analytics->trackImpression($post, $sessionKey); + + return response()->json(['counted' => $counted]); + } + + public function show(Request $request, int $id): JsonResponse + { + $post = Post::findOrFail($id); + + // Only the post owner can view analytics + if ($request->user()?->id !== $post->user_id) { + abort(403, 'You do not own this post.'); + } + + return response()->json(['data' => $this->analytics->getSummary($post)]); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostCommentController.php b/app/Http/Controllers/Api/Posts/PostCommentController.php new file mode 100644 index 00000000..8308377a --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostCommentController.php @@ -0,0 +1,122 @@ +query('page', 1)); + + $comments = PostComment::with(['user', 'user.profile']) + ->where('post_id', $post->id) + ->orderByDesc('is_highlighted') // highlighted first + ->orderBy('created_at') + ->paginate(20, ['*'], 'page', $page); + + $formatted = $comments->getCollection()->map(fn ($c) => $this->formatComment($c)); + + return response()->json([ + 'data' => $formatted, + 'meta' => [ + 'total' => $comments->total(), + 'current_page' => $comments->currentPage(), + 'last_page' => $comments->lastPage(), + 'per_page' => $comments->perPage(), + ], + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Store + // ───────────────────────────────────────────────────────────────────────── + + public function store(CreateCommentRequest $request, int $postId): JsonResponse + { + $user = $request->user(); + + // Rate limit: 30 comments per hour + $key = 'comment_post:' . $user->id; + if (RateLimiter::tooManyAttempts($key, 30)) { + $seconds = RateLimiter::availableIn($key); + return response()->json([ + 'message' => "You're commenting too quickly. Please wait {$seconds} seconds.", + ], 429); + } + RateLimiter::hit($key, 3600); + + $post = Post::findOrFail($postId); + $body = ContentSanitizer::render($request->input('body')); + + $comment = PostComment::create([ + 'post_id' => $post->id, + 'user_id' => $user->id, + 'body' => $body, + ]); + + $this->counters->incrementComments($post); + + // Fire event for notification + if ($post->user_id !== $user->id) { + event(new PostCommented($post, $comment, $user)); + } + + $comment->load(['user', 'user.profile']); + + return response()->json(['comment' => $this->formatComment($comment)], 201); + } + + // ───────────────────────────────────────────────────────────────────────── + // Destroy + // ───────────────────────────────────────────────────────────────────────── + + public function destroy(Request $request, int $postId, int $commentId): JsonResponse + { + $comment = PostComment::where('post_id', $postId)->findOrFail($commentId); + Gate::authorize('delete', $comment); + + $comment->delete(); + $this->counters->decrementComments(Post::findOrFail($postId)); + + return response()->json(['message' => 'Comment deleted.']); + } + + // ───────────────────────────────────────────────────────────────────────── + // Format + // ───────────────────────────────────────────────────────────────────────── + + private function formatComment(PostComment $comment): array + { + return [ + 'id' => $comment->id, + 'body' => $comment->body, + 'is_highlighted' => (bool) $comment->is_highlighted, + 'created_at' => $comment->created_at->toISOString(), + 'author' => [ + 'id' => $comment->user->id, + 'username' => $comment->user->username, + 'name' => $comment->user->name, + 'avatar' => $comment->user->profile?->avatar_url ?? null, + ], + ]; + } +} diff --git a/app/Http/Controllers/Api/Posts/PostCommentHighlightController.php b/app/Http/Controllers/Api/Posts/PostCommentHighlightController.php new file mode 100644 index 00000000..f7437846 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostCommentHighlightController.php @@ -0,0 +1,55 @@ +findOrFail($commentId); + + if ($request->user()->id !== $post->user_id) { + abort(403, 'Only the post owner can highlight comments.'); + } + + DB::transaction(function () use ($post, $comment) { + // Remove any existing highlight on this post + PostComment::where('post_id', $post->id) + ->where('is_highlighted', true) + ->update(['is_highlighted' => false]); + + $comment->update(['is_highlighted' => true]); + }); + + return response()->json(['message' => 'Comment highlighted.', 'comment_id' => $comment->id]); + } + + public function unhighlight(Request $request, int $postId, int $commentId): JsonResponse + { + $post = Post::findOrFail($postId); + $comment = PostComment::where('post_id', $postId)->findOrFail($commentId); + + if ($request->user()->id !== $post->user_id) { + abort(403, 'Only the post owner can remove comment highlights.'); + } + + $comment->update(['is_highlighted' => false]); + + return response()->json(['message' => 'Highlight removed.', 'comment_id' => $comment->id]); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostController.php b/app/Http/Controllers/Api/Posts/PostController.php new file mode 100644 index 00000000..ed8aad4f --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostController.php @@ -0,0 +1,92 @@ +user(); + + // Rate limit: 10 post creations per hour + $key = 'create_post:' . $user->id; + if (RateLimiter::tooManyAttempts($key, 10)) { + $seconds = RateLimiter::availableIn($key); + return response()->json([ + 'message' => "You're posting too quickly. Please wait {$seconds} seconds.", + ], 429); + } + RateLimiter::hit($key, 3600); + + Gate::authorize('create', Post::class); + + $post = $this->postService->createPost( + user: $user, + type: $request->input('type', Post::TYPE_TEXT), + visibility: $request->input('visibility', Post::VISIBILITY_PUBLIC), + body: $request->input('body'), + targets: $request->input('targets', []), + linkPreview: $request->input('link_preview'), + taggedUsers: $request->input('tagged_users'), publishAt: $request->filled('publish_at') ? Carbon::parse($request->input('publish_at')) : null, ); + + $post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']); + + return response()->json([ + 'post' => $this->feedService->formatPost($post, $user->id), + ], 201); + } + + // ───────────────────────────────────────────────────────────────────────── + // Update + // ───────────────────────────────────────────────────────────────────────── + + public function update(UpdatePostRequest $request, int $id): JsonResponse + { + $post = Post::findOrFail($id); + Gate::authorize('update', $post); + + $updated = $this->postService->updatePost( + post: $post, + body: $request->input('body'), + visibility: $request->input('visibility'), + ); + + return response()->json([ + 'post' => $this->feedService->formatPost($updated->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']), $request->user()?->id), + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Delete + // ───────────────────────────────────────────────────────────────────────── + + public function destroy(int $id): JsonResponse + { + $post = Post::findOrFail($id); + Gate::authorize('delete', $post); + + $this->postService->deletePost($post); + + return response()->json(['message' => 'Post deleted.']); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostFeedController.php b/app/Http/Controllers/Api/Posts/PostFeedController.php new file mode 100644 index 00000000..255fecc5 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostFeedController.php @@ -0,0 +1,60 @@ +firstOrFail(); + $viewerId = $request->user()?->id; + $page = max(1, (int) $request->query('page', 1)); + + $paginated = $this->feedService->getProfileFeed($profileUser, $viewerId, $page); + + $formatted = collect($paginated['data']) + ->map(fn ($post) => $this->feedService->formatPost($post, $viewerId)) + ->values(); + + return response()->json([ + 'data' => $formatted, + 'meta' => $paginated['meta'], + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Following feed — GET /api/posts/following + // ───────────────────────────────────────────────────────────────────────── + + public function following(Request $request): JsonResponse + { + $user = $request->user(); + $page = max(1, (int) $request->query('page', 1)); + $filter = $request->query('filter', 'all'); + + $result = $this->feedService->getFollowingFeed($user, $page, $filter); + + $viewerId = $user->id; + $formatted = array_map( + fn ($post) => $this->feedService->formatPost($post, $viewerId), + $result['data'], + ); + + return response()->json([ + 'data' => $formatted, + 'meta' => $result['meta'], + ]); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostPinController.php b/app/Http/Controllers/Api/Posts/PostPinController.php new file mode 100644 index 00000000..e43b3127 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostPinController.php @@ -0,0 +1,67 @@ +findOrFail($id); + Gate::authorize('update', $post); + + $user = $request->user(); + + // Count existing pinned posts + $pinnedCount = Post::where('user_id', $user->id) + ->where('is_pinned', true) + ->count(); + + if ($post->is_pinned) { + return response()->json(['message' => 'Post is already pinned.'], 409); + } + + if ($pinnedCount >= self::MAX_PINNED) { + return response()->json([ + 'message' => 'You can pin a maximum of ' . self::MAX_PINNED . ' posts.', + ], 422); + } + + $nextOrder = Post::where('user_id', $user->id) + ->where('is_pinned', true) + ->max('pinned_order') ?? 0; + + $post->update([ + 'is_pinned' => true, + 'pinned_order' => $nextOrder + 1, + ]); + + return response()->json(['message' => 'Post pinned.', 'post_id' => $post->id]); + } + + public function unpin(int $id): JsonResponse + { + $post = Post::findOrFail($id); + Gate::authorize('update', $post); + + if (! $post->is_pinned) { + return response()->json(['message' => 'Post is not pinned.'], 409); + } + + $post->update(['is_pinned' => false, 'pinned_order' => null]); + + return response()->json(['message' => 'Post unpinned.', 'post_id' => $post->id]); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostReactionController.php b/app/Http/Controllers/Api/Posts/PostReactionController.php new file mode 100644 index 00000000..b11c9092 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostReactionController.php @@ -0,0 +1,75 @@ +user(); + + $key = 'react_post:' . $user->id; + if (RateLimiter::tooManyAttempts($key, 60)) { + return response()->json(['message' => 'Too many reactions. Please slow down.'], 429); + } + RateLimiter::hit($key, 3600); + + $post = Post::findOrFail($id); + $reaction = $request->input('reaction', 'like'); + + $existing = PostReaction::where('post_id', $post->id) + ->where('user_id', $user->id) + ->where('reaction', $reaction) + ->first(); + + if ($existing) { + return response()->json(['message' => 'Already reacted.', 'reactions_count' => $post->reactions_count], 200); + } + + PostReaction::create([ + 'post_id' => $post->id, + 'user_id' => $user->id, + 'reaction' => $reaction, + ]); + + $this->counters->incrementReactions($post); + $post->refresh(); + + return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => true], 201); + } + + /** + * DELETE /api/posts/{id}/reactions/{reaction} + */ + public function destroy(Request $request, int $id, string $reaction = 'like'): JsonResponse + { + $user = $request->user(); + $post = Post::findOrFail($id); + + $deleted = PostReaction::where('post_id', $post->id) + ->where('user_id', $user->id) + ->where('reaction', $reaction) + ->delete(); + + if ($deleted) { + $this->counters->decrementReactions($post); + $post->refresh(); + } + + return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => false]); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostReportController.php b/app/Http/Controllers/Api/Posts/PostReportController.php new file mode 100644 index 00000000..0a2db410 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostReportController.php @@ -0,0 +1,49 @@ +user(); + $post = Post::findOrFail($id); + + Gate::authorize('report', $post); + + $request->validate([ + 'reason' => ['required', 'string', 'max:64'], + 'message' => ['nullable', 'string', 'max:1000'], + ]); + + // Unique report per user+post + $existing = PostReport::where('post_id', $post->id) + ->where('reporter_user_id', $user->id) + ->exists(); + + if ($existing) { + return response()->json(['message' => 'You have already reported this post.'], 409); + } + + PostReport::create([ + 'post_id' => $post->id, + 'reporter_user_id' => $user->id, + 'reason' => $request->input('reason'), + 'message' => $request->input('message'), + 'status' => 'open', + ]); + + return response()->json(['message' => 'Report submitted. Thank you for helping keep Skinbase safe.'], 201); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostSaveController.php b/app/Http/Controllers/Api/Posts/PostSaveController.php new file mode 100644 index 00000000..f281510b --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostSaveController.php @@ -0,0 +1,69 @@ +findOrFail($id); + $user = $request->user(); + + if (PostSave::where('post_id', $post->id)->where('user_id', $user->id)->exists()) { + return response()->json(['message' => 'Already saved.', 'saved' => true], 200); + } + + PostSave::create(['post_id' => $post->id, 'user_id' => $user->id]); + $this->counters->incrementSaves($post); + + return response()->json(['message' => 'Post saved.', 'saved' => true, 'saves_count' => $post->fresh()->saves_count]); + } + + public function unsave(Request $request, int $id): JsonResponse + { + $post = Post::findOrFail($id); + $user = $request->user(); + $save = PostSave::where('post_id', $post->id)->where('user_id', $user->id)->first(); + + if (! $save) { + return response()->json(['message' => 'Not saved.', 'saved' => false], 200); + } + + $save->delete(); + $this->counters->decrementSaves($post); + + return response()->json(['message' => 'Post unsaved.', 'saved' => false, 'saves_count' => $post->fresh()->saves_count]); + } + + public function index(Request $request): JsonResponse + { + $user = $request->user(); + $page = max(1, (int) $request->query('page', 1)); + $result = $this->feedService->getSavedFeed($user, $page); + + $formatted = array_map( + fn ($post) => $this->feedService->formatPost($post, $user->id), + $result['data'], + ); + + return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostSearchController.php b/app/Http/Controllers/Api/Posts/PostSearchController.php new file mode 100644 index 00000000..d83de521 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostSearchController.php @@ -0,0 +1,85 @@ +validate([ + 'q' => ['required', 'string', 'min:2', 'max:100'], + 'page' => ['nullable', 'integer', 'min:1'], + ]); + + $query = trim($request->input('q')); + $page = max(1, (int) $request->query('page', 1)); + $perPage = 20; + $viewerId = $request->user()?->id; + + // Scout search (Meilisearch) + try { + $results = Post::search($query) + ->where('visibility', Post::VISIBILITY_PUBLIC) + ->where('status', Post::STATUS_PUBLISHED) + ->paginate($perPage, 'page', $page); + + // Load relations + $results->load($this->feedService->publicEagerLoads()); + + $formatted = $results->getCollection() + ->map(fn ($post) => $this->feedService->formatPost($post, $viewerId)) + ->values(); + + return response()->json([ + 'data' => $formatted, + 'query' => $query, + 'meta' => [ + 'total' => $results->total(), + 'current_page' => $results->currentPage(), + 'last_page' => $results->lastPage(), + 'per_page' => $results->perPage(), + ], + ]); + } catch (\Exception $e) { + // Fallback: basic LIKE search on body + $paginated = Post::with($this->feedService->publicEagerLoads()) + ->where('status', Post::STATUS_PUBLISHED) + ->where('visibility', Post::VISIBILITY_PUBLIC) + ->where(function ($q) use ($query) { + $q->where('body', 'like', '%' . $query . '%') + ->orWhereHas('hashtags', fn ($hq) => $hq->where('tag', 'like', '%' . mb_strtolower($query) . '%')); + }) + ->orderByDesc('created_at') + ->paginate($perPage, ['*'], 'page', $page); + + $formatted = $paginated->getCollection() + ->map(fn ($post) => $this->feedService->formatPost($post, $viewerId)) + ->values(); + + return response()->json([ + 'data' => $formatted, + 'query' => $query, + 'meta' => [ + 'total' => $paginated->total(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + ], + ]); + } + } +} diff --git a/app/Http/Controllers/Api/Posts/PostShareController.php b/app/Http/Controllers/Api/Posts/PostShareController.php new file mode 100644 index 00000000..ebcf0394 --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostShareController.php @@ -0,0 +1,58 @@ +user(); + $artwork = Artwork::findOrFail($artworkId); + + // Rate limit: 10 artwork shares per hour + $key = 'share_artwork:' . $user->id; + if (RateLimiter::tooManyAttempts($key, 10)) { + $seconds = RateLimiter::availableIn($key); + return response()->json([ + 'message' => "You're sharing too quickly. Please wait {$seconds} seconds.", + ], 429); + } + RateLimiter::hit($key, 3600); + + $post = $this->shareService->shareArtwork( + user: $user, + artwork: $artwork, + body: $request->input('body'), + visibility: $request->input('visibility', 'public'), + ); + + $post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']); + + // Notify original artwork owner (unless self-share) + if ($artwork->user_id !== $user->id) { + event(new ArtworkShared($post, $artwork, $user)); + } + + return response()->json([ + 'post' => $this->feedService->formatPost($post, $user->id), + ], 201); + } +} diff --git a/app/Http/Controllers/Api/Posts/PostTrendingFeedController.php b/app/Http/Controllers/Api/Posts/PostTrendingFeedController.php new file mode 100644 index 00000000..5f06b7ea --- /dev/null +++ b/app/Http/Controllers/Api/Posts/PostTrendingFeedController.php @@ -0,0 +1,73 @@ +query('page', 1)); + $viewer = $request->user()?->id; + + $result = $this->trendingService->getTrending($viewer, $page); + + $formatted = array_map( + fn ($post) => $this->feedService->formatPost($post, $viewer), + $result['data'], + ); + + return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]); + } + + public function hashtag(Request $request, string $tag): JsonResponse + { + $tag = mb_strtolower(preg_replace('/[^A-Za-z0-9_]/', '', $tag)); + if (strlen($tag) < 2 || strlen($tag) > 64) { + return response()->json(['message' => 'Invalid hashtag.'], 422); + } + + $page = max(1, (int) $request->query('page', 1)); + $viewer = $request->user()?->id; + + $result = $this->feedService->getHashtagFeed($tag, $viewer, $page); + + $formatted = array_map( + fn ($post) => $this->feedService->formatPost($post, $viewer), + $result['data'], + ); + + return response()->json([ + 'tag' => $tag, + 'data' => array_values($formatted), + 'meta' => $result['meta'], + ]); + } + + public function trendingHashtags(): JsonResponse + { + $tags = Cache::remember('trending_hashtags', 300, function () { + return $this->hashtagService->trending(10, 24); + }); + + return response()->json(['hashtags' => $tags]); + } +} diff --git a/app/Http/Controllers/Api/ProfileApiController.php b/app/Http/Controllers/Api/ProfileApiController.php new file mode 100644 index 00000000..f94a17c8 --- /dev/null +++ b/app/Http/Controllers/Api/ProfileApiController.php @@ -0,0 +1,177 @@ +resolveUser($username); + if (! $user) { + return response()->json(['error' => 'User not found'], 404); + } + + $isOwner = Auth::check() && Auth::id() === $user->id; + $sort = $request->input('sort', 'latest'); + + $query = Artwork::with('user:id,name,username') + ->where('user_id', $user->id) + ->whereNull('deleted_at'); + + if (! $isOwner) { + $query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at'); + } + + $query = match ($sort) { + 'trending' => $query->orderByDesc('ranking_score'), + 'rising' => $query->orderByDesc('heat_score'), + 'views' => $query->orderByDesc('view_count'), + 'favs' => $query->orderByDesc('favourite_count'), + default => $query->orderByDesc('published_at'), + }; + + $perPage = 24; + $paginator = $query->cursorPaginate($perPage); + + $data = collect($paginator->items())->map(function (Artwork $art) { + $present = ThumbnailPresenter::present($art, 'md'); + return [ + 'id' => $art->id, + 'name' => $art->title, + 'thumb' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'width' => $art->width, + 'height' => $art->height, + 'username' => $art->user->username ?? null, + 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', + 'published_at' => $art->published_at, + ]; + })->values(); + + return response()->json([ + 'data' => $data, + 'next_cursor' => $paginator->nextCursor()?->encode(), + 'has_more' => $paginator->hasMorePages(), + ]); + } + + /** + * GET /api/profile/{username}/favourites + * Returns cursor-paginated favourites for the profile. + */ + public function favourites(Request $request, string $username): JsonResponse + { + if (! Schema::hasTable('user_favorites')) { + return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]); + } + + $user = $this->resolveUser($username); + if (! $user) { + return response()->json(['error' => 'User not found'], 404); + } + + $perPage = 24; + $cursor = $request->input('cursor'); + + $favIds = DB::table('user_favorites as uf') + ->join('artworks as a', 'a.id', '=', 'uf.artwork_id') + ->where('uf.user_id', $user->id) + ->whereNull('a.deleted_at') + ->where('a.is_public', true) + ->where('a.is_approved', true) + ->orderByDesc('uf.created_at') + ->offset($cursor ? (int) base64_decode($cursor) : 0) + ->limit($perPage + 1) + ->pluck('a.id'); + + $hasMore = $favIds->count() > $perPage; + $favIds = $favIds->take($perPage); + + if ($favIds->isEmpty()) { + return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]); + } + + $indexed = Artwork::with('user:id,name,username') + ->whereIn('id', $favIds) + ->get() + ->keyBy('id'); + + $data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) { + $art = $indexed[$id]; + $present = ThumbnailPresenter::present($art, 'md'); + return [ + 'id' => $art->id, + 'name' => $art->title, + 'thumb' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'width' => $art->width, + 'height' => $art->height, + 'username' => $art->user->username ?? null, + 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', + ]; + })->values(); + + return response()->json([ + 'data' => $data, + 'next_cursor' => null, // Simple offset pagination for now + 'has_more' => $hasMore, + ]); + } + + /** + * GET /api/profile/{username}/stats + * Returns profile statistics. + */ + public function stats(Request $request, string $username): JsonResponse + { + $user = $this->resolveUser($username); + if (! $user) { + return response()->json(['error' => 'User not found'], 404); + } + + $stats = null; + if (Schema::hasTable('user_statistics')) { + $stats = DB::table('user_statistics')->where('user_id', $user->id)->first(); + } + + $followerCount = 0; + if (Schema::hasTable('user_followers')) { + $followerCount = DB::table('user_followers')->where('user_id', $user->id)->count(); + } + + return response()->json([ + 'stats' => $stats, + 'follower_count' => $followerCount, + ]); + } + + private function resolveUser(string $username): ?User + { + $normalized = UsernamePolicy::normalize($username); + return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); + } +} diff --git a/app/Http/Controllers/Api/Search/UserSearchController.php b/app/Http/Controllers/Api/Search/UserSearchController.php index ce0e1a97..5a0a485c 100644 --- a/app/Http/Controllers/Api/Search/UserSearchController.php +++ b/app/Http/Controllers/Api/Search/UserSearchController.php @@ -31,13 +31,14 @@ final class UserSearchController extends Controller ->where('is_active', 1) ->whereNull('deleted_at') ->where(function ($qb) use ($q) { - $qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']); + $qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']) + ->orWhereRaw('LOWER(name) LIKE ?', ['%' . strtolower($q) . '%']); }) ->with(['profile', 'statistics']) ->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first ->orderBy('username') ->limit($perPage) - ->get(['id', 'username']); + ->get(['id', 'username', 'name']); $data = $users->map(function (User $user) { $username = strtolower((string) ($user->username ?? '')); @@ -48,6 +49,7 @@ final class UserSearchController extends Controller 'id' => $user->id, 'type' => 'user', 'username' => $username, + 'name' => $user->name ?? $username, 'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64), 'uploads_count' => $uploadsCount, 'profile_url' => '/@' . $username, diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 6b0baf88..0782ca6e 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -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); diff --git a/app/Http/Controllers/Dashboard/FavoriteController.php b/app/Http/Controllers/Dashboard/FavoriteController.php index cd1e6911..152d4d05 100644 --- a/app/Http/Controllers/Dashboard/FavoriteController.php +++ b/app/Http/Controllers/Dashboard/FavoriteController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\DB; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\View\View; +use App\Support\AvatarUrl; class FavoriteController extends Controller { @@ -36,16 +37,41 @@ class FavoriteController extends Controller $artworks = collect(); if ($slice !== []) { - $arts = Artwork::query()->whereIn('id', $slice)->with('user')->get()->keyBy('id'); + $arts = Artwork::query() + ->whereIn('id', $slice) + ->with(['user.profile', 'categories']) + ->withCount(['favourites', 'comments']) + ->get() + ->keyBy('id'); + foreach ($slice as $id) { $a = $arts->get($id); if (! $a) continue; + + $primaryCategory = $a->categories->sortBy('sort_order')->first(); + $username = $a->user?->username ?? $a->user?->name ?? ''; + $artworks->push((object) [ 'id' => $a->id, + 'name' => $a->title, 'title' => $a->title, 'thumb' => $a->thumbUrl('md') ?? $a->thumbnail_url ?? null, + 'thumb_url' => $a->thumbUrl('md') ?? $a->thumbnail_url ?? null, 'slug' => $a->slug, - 'author' => $a->user?->username ?? $a->user?->name, + 'author' => $username, + 'uname' => $username, + 'username' => $a->user?->username ?? '', + 'avatar_url' => AvatarUrl::forUser( + (int) ($a->user_id ?? 0), + $a->user?->profile?->avatar_hash ?? null, + 64 + ), + 'category_name' => $primaryCategory->name ?? '', + 'category_slug' => $primaryCategory->slug ?? '', + 'width' => $a->width, + 'height' => $a->height, + 'likes' => (int) ($a->favourites_count ?? $a->likes ?? 0), + 'comments_count' => (int) ($a->comments_count ?? 0), 'published_at' => $a->published_at, ]); } diff --git a/app/Http/Controllers/Forum/ForumController.php b/app/Http/Controllers/Forum/ForumController.php index 6e79c09a..447488b9 100644 --- a/app/Http/Controllers/Forum/ForumController.php +++ b/app/Http/Controllers/Forum/ForumController.php @@ -68,6 +68,7 @@ class ForumController extends Controller 'last_update' => $item->last_post_at ?? $item->created_at, 'uname' => $item->user?->name, 'num_posts' => (int) ($item->posts_count ?? 0), + 'is_pinned' => (bool) $item->is_pinned, ]; }); diff --git a/app/Http/Controllers/Studio/StudioArtworksApiController.php b/app/Http/Controllers/Studio/StudioArtworksApiController.php index 82e0f3d8..71febf5b 100644 --- a/app/Http/Controllers/Studio/StudioArtworksApiController.php +++ b/app/Http/Controllers/Studio/StudioArtworksApiController.php @@ -13,6 +13,7 @@ use App\Services\Studio\StudioBulkActionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; @@ -318,15 +319,17 @@ final class StudioArtworksApiController extends Controller $storage = app(\App\Services\Uploads\UploadStorageService::class); $artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class); - // 1. Store original on disk + // 1. Store original on disk (preserve extension when possible) $originalPath = $derivatives->storeOriginal($tempPath, $hash); - $originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp'); - $artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath)); + $origFilename = basename($originalPath); + $originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename); + $origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream'; + $artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath)); - // 2. Generate public derivatives (thumbnails) + // 2. Generate thumbnails (xs/sm/md/lg/xl) $publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash); foreach ($publicAbsolute as $variant => $absolutePath) { - $relativePath = $storage->publicRelativePath($hash, $variant . '.webp'); + $relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp'); $artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); } @@ -337,13 +340,14 @@ final class StudioArtworksApiController extends Controller $size = (int) filesize($originalPath); // 4. Update the artwork's file-serving fields (hash drives thumbnail URLs) + $origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: ''); $artwork->update([ - 'file_name' => 'orig.webp', + 'file_name' => $origFilename, 'file_path' => '', 'file_size' => $size, - 'mime_type' => 'image/webp', + 'mime_type' => $origMime, 'hash' => $hash, - 'file_ext' => 'webp', + 'file_ext' => $origExt, 'thumb_ext' => 'webp', 'width' => max(1, $width), 'height' => max(1, $height), diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index e850d18d..e87beb2c 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -25,6 +25,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\View\View; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules\Password as PasswordRule; +use Inertia\Inertia; class ProfileController extends Controller { @@ -220,6 +221,10 @@ class ProfileController extends Controller $profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } + if (array_key_exists('auto_post_upload', $validated)) { + $profileUpdates['auto_post_upload'] = filter_var($validated['auto_post_upload'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + } + if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature']; if (isset($validated['description'])) $profileUpdates['description'] = $validated['description']; @@ -498,24 +503,70 @@ class ProfileController extends Controller } catch (\Throwable) {} } - return response()->view('legacy::profile', [ - 'user' => $user, - 'profile' => $profile, - 'artworks' => $artworks, - 'featuredArtworks' => $featuredArtworks, - 'favourites' => $favourites, + // ── Normalise artworks for JSON serialisation ──────────────────── + $artworkItems = collect($artworks->items())->values(); + $artworkPayload = [ + 'data' => $artworkItems, + 'next_cursor' => $artworks->nextCursor()?->encode(), + 'has_more' => $artworks->hasMorePages(), + ]; + + // ── Avatar URL on user object ──────────────────────────────────── + $avatarUrl = AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128); + + // ── Auth context for JS ─────────────────────────────────────────── + $authData = null; + if (Auth::check()) { + /** @var \App\Models\User $authUser */ + $authUser = Auth::user(); + $authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64); + $authData = [ + 'user' => [ + 'id' => $authUser->id, + 'username' => $authUser->username, + 'name' => $authUser->name, + 'avatar' => $authAvatarUrl, + ], + ]; + } + + $canonical = url('/@' . strtolower((string) ($user->username ?? ''))); + + return Inertia::render('Profile/ProfileShow', [ + 'user' => [ + 'id' => $user->id, + 'username' => $user->username, + 'name' => $user->name, + 'avatar_url' => $avatarUrl, + 'created_at' => $user->created_at?->toISOString(), + 'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null, + ], + 'profile' => $profile ? [ + 'about' => $profile->about ?? null, + 'website' => $profile->website ?? null, + 'country_code' => $profile->country_code ?? null, + 'gender' => $profile->gender ?? null, + 'birthdate' => $profile->birthdate ?? null, + 'cover_image' => $profile->cover_image ?? null, + ] : null, + 'artworks' => $artworkPayload, + 'featuredArtworks' => $featuredArtworks->values(), + 'favourites' => $favourites->values(), 'stats' => $stats, 'socialLinks' => $socialLinks, 'followerCount' => $followerCount, - 'recentFollowers' => $recentFollowers, + 'recentFollowers' => $recentFollowers->values(), 'viewerIsFollowing' => $viewerIsFollowing, 'heroBgUrl' => $heroBgUrl, - 'profileComments' => $profileComments, + 'profileComments' => $profileComments->values(), 'countryName' => $countryName, 'isOwner' => $isOwner, - 'page_title' => 'Profile: ' . ($user->username ?? $user->name ?? ''), - 'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))), + 'auth' => $authData, + ])->withViewData([ + 'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase', + 'page_canonical' => $canonical, 'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.', + 'og_image' => $avatarUrl, ]); } } diff --git a/app/Http/Controllers/Web/Posts/FollowingFeedController.php b/app/Http/Controllers/Web/Posts/FollowingFeedController.php new file mode 100644 index 00000000..93151fcf --- /dev/null +++ b/app/Http/Controllers/Web/Posts/FollowingFeedController.php @@ -0,0 +1,30 @@ + [ + 'user' => $request->user() ? [ + 'id' => $request->user()->id, + 'username' => $request->user()->username, + 'name' => $request->user()->name, + 'avatar' => $request->user()->profile?->avatar_url ?? null, + ] : null, + ], + ]); + } +} diff --git a/app/Http/Controllers/Web/Posts/HashtagFeedController.php b/app/Http/Controllers/Web/Posts/HashtagFeedController.php new file mode 100644 index 00000000..c0a29ec9 --- /dev/null +++ b/app/Http/Controllers/Web/Posts/HashtagFeedController.php @@ -0,0 +1,27 @@ + $request->user() ? [ + 'user' => [ + 'id' => $request->user()->id, + 'username' => $request->user()->username, + 'name' => $request->user()->name, + 'avatar' => $request->user()->profile?->avatar_url ?? null, + ], + ] : null, + 'tag' => strtolower($tag), + ]); + } +} diff --git a/app/Http/Controllers/Web/Posts/SavedFeedController.php b/app/Http/Controllers/Web/Posts/SavedFeedController.php new file mode 100644 index 00000000..1057febb --- /dev/null +++ b/app/Http/Controllers/Web/Posts/SavedFeedController.php @@ -0,0 +1,26 @@ + [ + 'user' => [ + 'id' => $request->user()->id, + 'username' => $request->user()->username, + 'name' => $request->user()->name, + 'avatar' => $request->user()->profile?->avatar_url ?? null, + ], + ], + ]); + } +} diff --git a/app/Http/Controllers/Web/Posts/SearchFeedController.php b/app/Http/Controllers/Web/Posts/SearchFeedController.php new file mode 100644 index 00000000..89ee2609 --- /dev/null +++ b/app/Http/Controllers/Web/Posts/SearchFeedController.php @@ -0,0 +1,38 @@ + $this->hashtagService->trending(10, 24) + ); + + return Inertia::render('Feed/SearchFeed', [ + 'auth' => $request->user() ? [ + 'user' => [ + 'id' => $request->user()->id, + 'username' => $request->user()->username, + 'name' => $request->user()->name, + 'avatar' => $request->user()->profile?->avatar_url ?? null, + ], + ] : null, + 'initialQuery' => $request->query('q', ''), + 'trendingHashtags' => $trendingHashtags, + ]); + } +} diff --git a/app/Http/Controllers/Web/Posts/TrendingFeedController.php b/app/Http/Controllers/Web/Posts/TrendingFeedController.php new file mode 100644 index 00000000..669a55e1 --- /dev/null +++ b/app/Http/Controllers/Web/Posts/TrendingFeedController.php @@ -0,0 +1,33 @@ + $this->hashtagService->trending(10, 24)); + + return Inertia::render('Feed/TrendingFeed', [ + 'auth' => $request->user() ? [ + 'user' => [ + 'id' => $request->user()->id, + 'username' => $request->user()->username, + 'name' => $request->user()->name, + 'avatar' => $request->user()->profile?->avatar_url ?? null, + ], + ] : null, + 'trendingHashtags' => $trendingHashtags, + ]); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2f273470..b01d22a3 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -20,6 +20,29 @@ final class HandleInertiaRequests extends Middleware return 'studio'; } + // Profile pages: /@{username} + if (str_starts_with($request->path(), '@')) { + return 'profile.show'; + } + + // Feed pages — ordered most-specific first + if ($request->path() === 'feed/trending') { + return 'feed.trending'; + } + + if ($request->path() === 'feed/saved') { + return 'feed.saved'; + } + + if (str_starts_with($request->path(), 'feed')) { + return 'feed.following'; + } + + // Hashtag pages: /tags/{tag} + if (str_starts_with($request->path(), 'tags/')) { + return 'feed.hashtag'; + } + return $this->rootView; } diff --git a/app/Http/Requests/Posts/CreateCommentRequest.php b/app/Http/Requests/Posts/CreateCommentRequest.php new file mode 100644 index 00000000..0625baad --- /dev/null +++ b/app/Http/Requests/Posts/CreateCommentRequest.php @@ -0,0 +1,27 @@ +user(); + } + + public function rules(): array + { + return [ + 'body' => ['required', 'string', 'min:1', 'max:1000'], + ]; + } + + public function messages(): array + { + return [ + 'body.max' => 'Comment cannot exceed 1,000 characters.', + ]; + } +} diff --git a/app/Http/Requests/Posts/CreatePostRequest.php b/app/Http/Requests/Posts/CreatePostRequest.php new file mode 100644 index 00000000..9d0b28d1 --- /dev/null +++ b/app/Http/Requests/Posts/CreatePostRequest.php @@ -0,0 +1,43 @@ +user(); + } + + public function rules(): array + { + return [ + 'type' => ['required', 'string', 'in:text,artwork_share,upload,achievement'], + 'visibility' => ['required', 'string', 'in:public,followers,private'], + 'body' => ['nullable', 'string', 'max:2000'], + 'targets' => ['nullable', 'array', 'max:1'], + 'targets.*.type' => ['required_with:targets', 'string', 'in:artwork'], + 'targets.*.id' => ['required_with:targets', 'integer', 'min:1'], + 'link_preview' => ['nullable', 'array'], + 'link_preview.url' => ['nullable', 'string', 'url', 'max:2048'], + 'link_preview.title' => ['nullable', 'string', 'max:300'], + 'link_preview.description' => ['nullable', 'string', 'max:500'], + 'link_preview.image' => ['nullable', 'string', 'url', 'max:2048'], + 'link_preview.site_name' => ['nullable', 'string', 'max:100'], + 'tagged_users' => ['nullable', 'array', 'max:10'], + 'tagged_users.*.id' => ['required_with:tagged_users', 'integer', 'min:1'], + 'tagged_users.*.username' => ['required_with:tagged_users', 'string', 'max:50'], + 'tagged_users.*.name' => ['nullable', 'string', 'max:100'], + 'publish_at' => ['nullable', 'date', 'after:now'], + ]; + } + + public function messages(): array + { + return [ + 'body.max' => 'Post body cannot exceed 2,000 characters.', + ]; + } +} diff --git a/app/Http/Requests/Posts/ShareArtworkRequest.php b/app/Http/Requests/Posts/ShareArtworkRequest.php new file mode 100644 index 00000000..7c566327 --- /dev/null +++ b/app/Http/Requests/Posts/ShareArtworkRequest.php @@ -0,0 +1,21 @@ +user(); + } + + public function rules(): array + { + return [ + 'body' => ['nullable', 'string', 'max:2000'], + 'visibility' => ['required', 'string', 'in:public,followers,private'], + ]; + } +} diff --git a/app/Http/Requests/Posts/UpdatePostRequest.php b/app/Http/Requests/Posts/UpdatePostRequest.php new file mode 100644 index 00000000..9b20322f --- /dev/null +++ b/app/Http/Requests/Posts/UpdatePostRequest.php @@ -0,0 +1,21 @@ +user(); + } + + public function rules(): array + { + return [ + 'body' => ['nullable', 'string', 'max:2000'], + 'visibility' => ['nullable', 'string', 'in:public,followers,private'], + ]; + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index adac00a7..9ba6b166 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -35,6 +35,7 @@ class ProfileUpdateRequest extends FormRequest 'country' => ['nullable', 'string', 'max:10'], 'mailing' => ['nullable', 'boolean'], 'notify' => ['nullable', 'boolean'], + 'auto_post_upload' => ['nullable', 'boolean'], 'about' => ['nullable', 'string'], 'signature' => ['nullable', 'string'], 'description' => ['nullable', 'string'], diff --git a/app/Http/Requests/Uploads/UploadFinishRequest.php b/app/Http/Requests/Uploads/UploadFinishRequest.php index b0553fbf..22d6bad1 100644 --- a/app/Http/Requests/Uploads/UploadFinishRequest.php +++ b/app/Http/Requests/Uploads/UploadFinishRequest.php @@ -78,6 +78,7 @@ final class UploadFinishRequest extends FormRequest 'session_id' => 'required|uuid', 'artwork_id' => 'required|integer', 'upload_token' => 'nullable|string|min:40|max:200', + 'file_name' => 'nullable|string|max:255', ]; } diff --git a/app/Jobs/GenerateDerivativesJob.php b/app/Jobs/GenerateDerivativesJob.php index 3732b6bf..ad75dad7 100644 --- a/app/Jobs/GenerateDerivativesJob.php +++ b/app/Jobs/GenerateDerivativesJob.php @@ -23,13 +23,14 @@ final class GenerateDerivativesJob implements ShouldQueue public function __construct( private readonly string $sessionId, private readonly string $hash, - private readonly int $artworkId + private readonly int $artworkId, + private readonly ?string $originalFileName = null ) { } public function handle(UploadPipelineService $pipeline): void { - $pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId); + $pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId, $this->originalFileName); // Auto-tagging is async and must never block publish. AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit(); diff --git a/app/Jobs/Posts/AutoUploadPostJob.php b/app/Jobs/Posts/AutoUploadPostJob.php new file mode 100644 index 00000000..47950bb9 --- /dev/null +++ b/app/Jobs/Posts/AutoUploadPostJob.php @@ -0,0 +1,70 @@ +artworkId); + $user = User::find($this->userId); + + if (! $artwork || ! $user) { + return; + } + + // If post already exists for this artwork, skip (idempotent) + $exists = Post::where('user_id', $user->id) + ->where('type', Post::TYPE_UPLOAD) + ->whereHas('targets', fn ($q) => $q->where('target_type', 'artwork')->where('target_id', $artwork->id)) + ->exists(); + + if ($exists) { + return; + } + + DB::transaction(function () use ($artwork, $user, $hashtagService) { + $post = Post::create([ + 'user_id' => $user->id, + 'type' => Post::TYPE_UPLOAD, + 'visibility' => Post::VISIBILITY_PUBLIC, + 'body' => null, + 'status' => Post::STATUS_PUBLISHED, + ]); + + PostTarget::create([ + 'post_id' => $post->id, + 'target_type' => 'artwork', + 'target_id' => $artwork->id, + ]); + }); + + Log::info("AutoUploadPostJob: created upload post for artwork #{$this->artworkId} by user #{$this->userId}"); + } +} diff --git a/app/Listeners/Posts/SendArtworkSharedNotification.php b/app/Listeners/Posts/SendArtworkSharedNotification.php new file mode 100644 index 00000000..14f3c5db --- /dev/null +++ b/app/Listeners/Posts/SendArtworkSharedNotification.php @@ -0,0 +1,27 @@ +artwork->user; + + // Don't notify if sharer is the owner + if ($originalOwner->id === $event->sharer->id) { + return; + } + + $originalOwner->notify(new ArtworkSharedNotification( + post: $event->post, + artwork: $event->artwork, + sharer: $event->sharer, + )); + } +} diff --git a/app/Listeners/Posts/SendPostCommentedNotification.php b/app/Listeners/Posts/SendPostCommentedNotification.php new file mode 100644 index 00000000..e73ad1f5 --- /dev/null +++ b/app/Listeners/Posts/SendPostCommentedNotification.php @@ -0,0 +1,27 @@ +post->user; + + // Don't notify for self-comments + if ($postOwner->id === $event->commenter->id) { + return; + } + + $postOwner->notify(new PostCommentedNotification( + post: $event->post, + comment: $event->comment, + commenter: $event->commenter, + )); + } +} diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index aca834e2..37df5f90 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -54,12 +54,17 @@ class Artwork extends Model 'version_count', 'version_updated_at', 'requires_reapproval', + // Scheduled publishing + 'publish_at', + 'artwork_status', + 'artwork_timezone', ]; protected $casts = [ 'is_public' => 'boolean', 'is_approved' => 'boolean', 'published_at' => 'datetime', + 'publish_at' => 'datetime', 'version_updated_at' => 'datetime', 'requires_reapproval' => 'boolean', ]; diff --git a/app/Models/Post.php b/app/Models/Post.php new file mode 100644 index 00000000..adcf6734 --- /dev/null +++ b/app/Models/Post.php @@ -0,0 +1,185 @@ + 'array', + 'reactions_count' => 'integer', + 'comments_count' => 'integer', + 'impressions_count' => 'integer', + 'saves_count' => 'integer', + 'engagement_score' => 'float', + 'is_pinned' => 'boolean', + 'pinned_order' => 'integer', + 'publish_at' => 'datetime', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Constants + // ───────────────────────────────────────────────────────────────────────── + + public const TYPE_TEXT = 'text'; + public const TYPE_ARTWORK_SHARE = 'artwork_share'; + public const TYPE_UPLOAD = 'upload'; + public const TYPE_ACHIEVEMENT = 'achievement'; + + public const VISIBILITY_PUBLIC = 'public'; + public const VISIBILITY_FOLLOWERS = 'followers'; + public const VISIBILITY_PRIVATE = 'private'; + + public const STATUS_DRAFT = 'draft'; + public const STATUS_SCHEDULED = 'scheduled'; + public const STATUS_PUBLISHED = 'published'; + + // ───────────────────────────────────────────────────────────────────────── + // Relationships + // ───────────────────────────────────────────────────────────────────────── + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function targets(): HasMany + { + return $this->hasMany(PostTarget::class); + } + + /** Convenience: single artwork target for artwork_share posts */ + public function artworkTarget(): HasOne + { + return $this->hasOne(PostTarget::class)->where('target_type', 'artwork'); + } + + public function reactions(): HasMany + { + return $this->hasMany(PostReaction::class); + } + + public function comments(): HasMany + { + return $this->hasMany(PostComment::class)->orderBy('created_at'); + } + + public function saves(): HasMany + { + return $this->hasMany(PostSave::class); + } + + public function reports(): HasMany + { + return $this->hasMany(PostReport::class); + } + + public function hashtags(): HasMany + { + return $this->hasMany(PostHashtag::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** Posts visible to any public (non-authenticated) visitor */ + public function scopePublic($query) + { + return $query->where('visibility', self::VISIBILITY_PUBLIC); + } + + /** Only published posts */ + public function scopePublished($query) + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + /** Only scheduled posts */ + public function scopeScheduled($query) + { + return $query->where('status', self::STATUS_SCHEDULED); + } + + /** Posts visible to the given viewer (respects followers-only AND published status) */ + public function scopeVisibleTo($query, ?int $viewerId) + { + $query->where('status', self::STATUS_PUBLISHED); + + if (! $viewerId) { + return $query->where('visibility', self::VISIBILITY_PUBLIC); + } + + return $query->where(function ($q) use ($viewerId) { + $q->where('visibility', self::VISIBILITY_PUBLIC) + ->orWhere('user_id', $viewerId) + ->orWhere(function ($q2) use ($viewerId) { + $q2->where('visibility', self::VISIBILITY_FOLLOWERS) + ->whereIn('user_id', function ($sub) use ($viewerId) { + $sub->select('user_id') + ->from('user_followers') + ->where('follower_id', $viewerId); + }); + }); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scout (Meilisearch) + // ───────────────────────────────────────────────────────────────────────── + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'body' => strip_tags($this->body ?? ''), + 'hashtags' => $this->hashtags->pluck('tag')->toArray(), + 'user_id' => $this->user_id, + 'type' => $this->type, + 'visibility' => $this->visibility, + 'created_at' => $this->created_at?->timestamp, + ]; + } + + public function shouldBeSearchable(): bool + { + return $this->status === self::STATUS_PUBLISHED + && $this->visibility === self::VISIBILITY_PUBLIC; + } +} diff --git a/app/Models/PostComment.php b/app/Models/PostComment.php new file mode 100644 index 00000000..644e18ff --- /dev/null +++ b/app/Models/PostComment.php @@ -0,0 +1,35 @@ + 'boolean', + ]; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/PostHashtag.php b/app/Models/PostHashtag.php new file mode 100644 index 00000000..9f35222c --- /dev/null +++ b/app/Models/PostHashtag.php @@ -0,0 +1,31 @@ +attributes['tag'] = mb_strtolower($value); + } + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/PostReaction.php b/app/Models/PostReaction.php new file mode 100644 index 00000000..b011649f --- /dev/null +++ b/app/Models/PostReaction.php @@ -0,0 +1,27 @@ +belongsTo(Post::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/PostReport.php b/app/Models/PostReport.php new file mode 100644 index 00000000..440761b9 --- /dev/null +++ b/app/Models/PostReport.php @@ -0,0 +1,29 @@ +belongsTo(Post::class); + } + + public function reporter(): BelongsTo + { + return $this->belongsTo(User::class, 'reporter_user_id'); + } +} diff --git a/app/Models/PostSave.php b/app/Models/PostSave.php new file mode 100644 index 00000000..88e2d699 --- /dev/null +++ b/app/Models/PostSave.php @@ -0,0 +1,35 @@ + 'datetime', + ]; + + const CREATED_AT = 'created_at'; + const UPDATED_AT = null; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/PostTarget.php b/app/Models/PostTarget.php new file mode 100644 index 00000000..e235efa4 --- /dev/null +++ b/app/Models/PostTarget.php @@ -0,0 +1,41 @@ + 'datetime', + ]; + + const CREATED_AT = 'created_at'; + const UPDATED_AT = null; + + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + /** Resolved Artwork when target_type = 'artwork' */ + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class, 'target_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a6fe71a4..6a1fce45 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -210,6 +210,11 @@ class User extends Authenticatable return $this->hasRole('moderator'); } + public function posts(): HasMany + { + return $this->hasMany(Post::class)->orderByDesc('created_at'); + } + // ─── Meilisearch ────────────────────────────────────────────────────────── /** diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php index 7bb3de69..9b0fe0a4 100644 --- a/app/Models/UserProfile.php +++ b/app/Models/UserProfile.php @@ -29,11 +29,13 @@ class UserProfile extends Model 'birthdate', 'gender', 'website', + 'auto_post_upload', ]; protected $casts = [ - 'birthdate' => 'date', - 'avatar_updated_at' => 'datetime', + 'birthdate' => 'date', + 'avatar_updated_at'=> 'datetime', + 'auto_post_upload' => 'boolean', ]; public $timestamps = true; diff --git a/app/Notifications/ArtworkSharedNotification.php b/app/Notifications/ArtworkSharedNotification.php new file mode 100644 index 00000000..d969f749 --- /dev/null +++ b/app/Notifications/ArtworkSharedNotification.php @@ -0,0 +1,41 @@ + 'artwork_shared', + 'post_id' => $this->post->id, + 'artwork_id' => $this->artwork->id, + 'artwork_title' => $this->artwork->title, + 'sharer_id' => $this->sharer->id, + 'sharer_name' => $this->sharer->name, + 'sharer_username' => $this->sharer->username, + 'message' => "{$this->sharer->name} shared your artwork "{$this->artwork->title}"", + 'url' => "/@{$this->sharer->username}?tab=posts", + ]; + } +} diff --git a/app/Notifications/PostCommentedNotification.php b/app/Notifications/PostCommentedNotification.php new file mode 100644 index 00000000..e32930ea --- /dev/null +++ b/app/Notifications/PostCommentedNotification.php @@ -0,0 +1,40 @@ + 'post_commented', + 'post_id' => $this->post->id, + 'comment_id' => $this->comment->id, + 'commenter_id' => $this->commenter->id, + 'commenter_name' => $this->commenter->name, + 'commenter_username' => $this->commenter->username, + 'message' => "{$this->commenter->name} commented on your post", + 'url' => "/@{$this->post->user->username}?tab=posts", + ]; + } +} diff --git a/app/Observers/ArtworkObserver.php b/app/Observers/ArtworkObserver.php index 067086f2..d6569bc1 100644 --- a/app/Observers/ArtworkObserver.php +++ b/app/Observers/ArtworkObserver.php @@ -7,6 +7,7 @@ namespace App\Observers; use App\Models\Artwork; use App\Jobs\RecComputeSimilarByTagsJob; use App\Jobs\RecComputeSimilarHybridJob; +use App\Jobs\Posts\AutoUploadPostJob; use App\Services\ArtworkSearchIndexer; use App\Services\UserStatsService; @@ -48,6 +49,16 @@ class ArtworkObserver if ($artwork->is_public && $artwork->published_at) { RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30)); RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinutes(1)); + + // Auto-upload post: fire only when artwork transitions to published for the first time + if ($artwork->wasChanged('published_at') && $artwork->published_at !== null) { + $user = $artwork->user; + $autoPost = $user?->profile?->auto_post_upload ?? true; + if ($autoPost) { + AutoUploadPostJob::dispatch($artwork->id, $artwork->user_id) + ->delay(now()->addSeconds(5)); + } + } } } diff --git a/app/Policies/PostCommentPolicy.php b/app/Policies/PostCommentPolicy.php new file mode 100644 index 00000000..4c5353e1 --- /dev/null +++ b/app/Policies/PostCommentPolicy.php @@ -0,0 +1,16 @@ +id === $comment->user_id + || $user->isAdmin() + || $user->isModerator(); + } +} diff --git a/app/Policies/PostPolicy.php b/app/Policies/PostPolicy.php new file mode 100644 index 00000000..423743aa --- /dev/null +++ b/app/Policies/PostPolicy.php @@ -0,0 +1,75 @@ +id === $post->user_id; + } + + /** Author or admin/moderator can delete */ + public function delete(User $user, Post $post): bool + { + return $user->id === $post->user_id + || $user->isAdmin() + || $user->isModerator(); + } + + /** Anyone can view public posts; followers-only requires following */ + public function view(?User $user, Post $post): bool + { + if ($post->visibility === Post::VISIBILITY_PUBLIC) { + return true; + } + + if (! $user) { + return false; + } + + if ($user->id === $post->user_id) { + return true; + } + + if ($post->visibility === Post::VISIBILITY_FOLLOWERS) { + return $post->user->isFollowedBy($user->id); + } + + return false; + } + + /** Only the author can report their own posts */ + public function report(User $user, Post $post): bool + { + return $user->id !== $post->user_id; + } + + /** Only the post owner can pin/unpin their own post */ + public function pin(User $user, Post $post): bool + { + return $user->id === $post->user_id; + } + + /** Any authenticated user can save a post (own or others') */ + public function save(User $user, Post $post): bool + { + return $post->status === Post::STATUS_PUBLISHED; + } + + /** Only post owner may highlight a comment */ + public function highlightComment(User $user, Post $post): bool + { + return $user->id === $post->user_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e3c15758..37b20bb3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -65,6 +65,16 @@ class AppServiceProvider extends ServiceProvider ArtworkComment::observe(ArtworkCommentObserver::class); ArtworkReaction::observe(ArtworkReactionObserver::class); + // ── Posts / Feed System Events ────────────────────────────────────── + Event::listen( + \App\Events\Posts\ArtworkShared::class, + \App\Listeners\Posts\SendArtworkSharedNotification::class, + ); + Event::listen( + \App\Events\Posts\PostCommented::class, + \App\Listeners\Posts\SendPostCommentedNotification::class, + ); + // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { $uploadCount = $favCount = $msgCount = $noticeCount = 0; diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 2600e01a..13b66433 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -6,21 +6,22 @@ use Illuminate\Support\Facades\Gate; use App\Models\Artwork; use App\Models\ArtworkAward; use App\Models\ArtworkComment; +use App\Models\Post; +use App\Models\PostComment; use App\Policies\ArtworkPolicy; use App\Policies\ArtworkAwardPolicy; use App\Policies\ArtworkCommentPolicy; +use App\Policies\PostPolicy; +use App\Policies\PostCommentPolicy; class AuthServiceProvider extends ServiceProvider { - /** - * The policy mappings for the application. - * - * @var array - */ protected $policies = [ Artwork::class => ArtworkPolicy::class, ArtworkAward::class => ArtworkAwardPolicy::class, ArtworkComment::class => ArtworkCommentPolicy::class, + Post::class => PostPolicy::class, + PostComment::class => PostCommentPolicy::class, ]; /** diff --git a/app/Services/Posts/NotificationDigestService.php b/app/Services/Posts/NotificationDigestService.php new file mode 100644 index 00000000..40a2c0a9 --- /dev/null +++ b/app/Services/Posts/NotificationDigestService.php @@ -0,0 +1,115 @@ +type, data->post_id, 1-hour bucket) + */ +class NotificationDigestService +{ + /** + * Aggregate a raw notification collection into digest entries. + * + * @param \Illuminate\Database\Eloquent\Collection $notifications + * @return array + */ + public function aggregate(Collection $notifications): array + { + $groups = []; + + foreach ($notifications as $notif) { + $data = is_array($notif->data) ? $notif->data : json_decode($notif->data, true); + $type = $data['type'] ?? 'unknown'; + $postId = $data['post_id'] ?? null; + + // 1-hour bucket + $bucket = $notif->created_at->format('Y-m-d H'); + + $key = "{$type}:{$postId}:{$bucket}"; + + if (! isset($groups[$key])) { + $groups[$key] = [ + 'type' => $type, + 'count' => 0, + 'actors' => [], + 'post_id' => $postId, + 'url' => $data['url'] ?? null, + 'latest_at' => $notif->created_at->toISOString(), + 'read' => $notif->read_at !== null, + 'ids' => [], + ]; + } + + $groups[$key]['count']++; + $groups[$key]['ids'][] = $notif->id; + + // Collect unique actors (up to 5 for display) + $actorId = $data['commenter_id'] ?? $data['reactor_id'] ?? $data['actor_id'] ?? null; + if ($actorId && count($groups[$key]['actors']) < 5) { + $alreadyAdded = collect($groups[$key]['actors'])->contains('id', $actorId); + if (! $alreadyAdded) { + $groups[$key]['actors'][] = [ + 'id' => $actorId, + 'name' => $data['commenter_name'] ?? $data['actor_name'] ?? null, + 'username' => $data['commenter_username'] ?? $data['actor_username'] ?? null, + ]; + } + } + + if ($notif->read_at === null) { + $groups[$key]['read'] = false; // group is unread if any item is unread + } + } + + // Build readable message for each group + $result = []; + foreach ($groups as $group) { + $group['message'] = $this->buildMessage($group); + $result[] = $group; + } + + // Sort by latest_at descending + usort($result, fn ($a, $b) => strcmp($b['latest_at'], $a['latest_at'])); + + return $result; + } + + private function buildMessage(array $group): string + { + $count = $group['count']; + $actors = $group['actors']; + $first = $actors[0]['name'] ?? 'Someone'; + $others = $count - 1; + + return match ($group['type']) { + 'post_commented' => $count === 1 + ? "{$first} commented on your post" + : "{$first} and {$others} other(s) commented on your post", + 'post_liked' => $count === 1 + ? "{$first} liked your post" + : "{$first} and {$others} other(s) liked your post", + 'post_shared' => $count === 1 + ? "{$first} shared your artwork" + : "{$first} and {$others} other(s) shared your artwork", + default => $count === 1 + ? "{$first} interacted with your post" + : "{$count} people interacted with your post", + }; + } +} diff --git a/app/Services/Posts/PostAchievementService.php b/app/Services/Posts/PostAchievementService.php new file mode 100644 index 00000000..eb0ff9e1 --- /dev/null +++ b/app/Services/Posts/PostAchievementService.php @@ -0,0 +1,109 @@ +createAchievementPost($user, "follower_{$milestone}", [ + 'milestone' => $milestone, + 'message' => "🎉 Just reached {$milestone} followers! Thank you all!", + ]); + break; + } + } + } + + /** + * Check if an artwork's view count crosses a milestone. + */ + public function maybeArtworkViewMilestone(User $user, int $artworkId, int $newViewCount): void + { + foreach (self::VIEW_MILESTONES as $milestone) { + if ($newViewCount === $milestone) { + $this->createAchievementPost($user, "artwork_{$milestone}_views", [ + 'artwork_id' => $artworkId, + 'milestone' => $milestone, + 'message' => "🎨 One of my artworks just hit {$milestone} views!", + ], $artworkId); + break; + } + } + } + + /** + * Create an achievement post for receiving an award. + */ + public function awardReceived(User $user, string $awardName, ?int $artworkId = null): void + { + $this->createAchievementPost($user, 'award_received', [ + 'award_name' => $awardName, + 'artwork_id' => $artworkId, + 'message' => "🏆 Just received the \"{$awardName}\" award!", + ], $artworkId); + } + + // ───────────────────────────────────────────────────────────────────────── + + private function createAchievementPost( + User $user, + string $achievementType, + array $meta, + ?int $artworkId = null, + ): void { + // Deduplicate: don't create the same achievement post twice + $exists = Post::where('user_id', $user->id) + ->where('type', Post::TYPE_ACHIEVEMENT) + ->whereJsonContains('meta->achievement_type', $achievementType) + ->exists(); + + if ($exists) { + return; + } + + DB::transaction(function () use ($user, $achievementType, $meta, $artworkId) { + $post = Post::create([ + 'user_id' => $user->id, + 'type' => Post::TYPE_ACHIEVEMENT, + 'visibility' => Post::VISIBILITY_PUBLIC, + 'body' => $meta['message'] ?? null, + 'status' => Post::STATUS_PUBLISHED, + 'meta' => array_merge($meta, ['achievement_type' => $achievementType]), + ]); + + if ($artworkId) { + PostTarget::create([ + 'post_id' => $post->id, + 'target_type' => 'artwork', + 'target_id' => $artworkId, + ]); + } + }); + + Log::info("PostAchievementService: created '{$achievementType}' post for user #{$user->id}"); + } +} diff --git a/app/Services/Posts/PostAnalyticsService.php b/app/Services/Posts/PostAnalyticsService.php new file mode 100644 index 00000000..1246f2a5 --- /dev/null +++ b/app/Services/Posts/PostAnalyticsService.php @@ -0,0 +1,81 @@ +id}:{$sessionKey}"; + + if (Cache::has($cacheKey)) { + return false; // already counted this hour + } + + Cache::put($cacheKey, 1, now()->addHour()); + + Post::withoutTimestamps(function () use ($post) { + DB::table('posts') + ->where('id', $post->id) + ->increment('impressions_count'); + }); + + // Recompute engagement score asynchronously via a quick DB update + $this->refreshEngagementScore($post->id); + + return true; + } + + /** + * Refresh the cached engagement_score = (reactions*2 + comments*3 + saves) / max(impressions, 1) + */ + public function refreshEngagementScore(int $postId): void + { + Post::withoutTimestamps(function () use ($postId) { + DB::table('posts') + ->where('id', $postId) + ->update([ + 'engagement_score' => DB::raw( + '(reactions_count * 2 + comments_count * 3 + saves_count) / GREATEST(impressions_count, 1)' + ), + ]); + }); + } + + /** + * Return analytics summary for a post (owner view). + */ + public function getSummary(Post $post): array + { + $reactions = $post->reactions_count; + $comments = $post->comments_count; + $saves = $post->saves_count; + $impressions = $post->impressions_count; + $rate = $impressions > 0 + ? round((($reactions + $comments + $saves) / $impressions) * 100, 2) + : 0.0; + + return [ + 'impressions' => $impressions, + 'reactions' => $reactions, + 'comments' => $comments, + 'saves' => $saves, + 'engagement_rate' => $rate, // percentage + 'engagement_score' => round($post->engagement_score, 4), + ]; + } +} diff --git a/app/Services/Posts/PostCountersService.php b/app/Services/Posts/PostCountersService.php new file mode 100644 index 00000000..b7dc8cfa --- /dev/null +++ b/app/Services/Posts/PostCountersService.php @@ -0,0 +1,68 @@ +increment('reactions_count'); + } + + public function decrementReactions(Post $post): void + { + DB::table('posts') + ->where('id', $post->id) + ->where('reactions_count', '>', 0) + ->decrement('reactions_count'); + } + + public function incrementComments(Post $post): void + { + $post->increment('comments_count'); + } + + public function decrementComments(Post $post): void + { + DB::table('posts') + ->where('id', $post->id) + ->where('comments_count', '>', 0) + ->decrement('comments_count'); + } + + /** + * Recompute both counters from scratch (repair tool). + */ + public function recompute(Post $post): void + { + Post::withoutTimestamps(function () use ($post) { + $post->update([ + 'reactions_count' => PostReaction::where('post_id', $post->id)->count(), + 'comments_count' => PostComment::where('post_id', $post->id)->whereNull('deleted_at')->count(), + 'saves_count' => \App\Models\PostSave::where('post_id', $post->id)->count(), + ]); + }); + } + + public function incrementSaves(Post $post): void + { + $post->increment('saves_count'); + } + + public function decrementSaves(Post $post): void + { + DB::table('posts') + ->where('id', $post->id) + ->where('saves_count', '>', 0) + ->decrement('saves_count'); + } +} diff --git a/app/Services/Posts/PostFeedService.php b/app/Services/Posts/PostFeedService.php new file mode 100644 index 00000000..d7438d83 --- /dev/null +++ b/app/Services/Posts/PostFeedService.php @@ -0,0 +1,262 @@ +eagerLoads()) + ->where('user_id', $profileUser->id) + ->visibleTo($viewerId); + + // Pinned posts (always on page 1, regardless of pagination) + $pinned = (clone $baseQuery) + ->where('is_pinned', true) + ->orderBy('pinned_order') + ->get(); + + $paginated = (clone $baseQuery) + ->orderByDesc('created_at') + ->paginate(self::PER_PAGE, ['*'], 'page', $page); + + // On page 1, prepend pinned posts (deduplicated) + $paginatedCollection = $paginated->getCollection(); + if ($page === 1 && $pinned->isNotEmpty()) { + $pinnedIds = $pinned->pluck('id'); + $rest = $paginatedCollection->reject(fn ($p) => $pinnedIds->contains($p->id)); + $combined = $pinned->concat($rest); + } else { + $combined = $paginatedCollection->reject(fn ($p) => $p->is_pinned && $page === 1); + } + + return [ + 'data' => $combined->values()->all(), + 'meta' => [ + 'total' => $paginated->total(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + ], + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Following feed (ranked + diversity-limited) + // ───────────────────────────────────────────────────────────────────────── + + public function getFollowingFeed( + User $viewer, + int $page = 1, + string $filter = 'all', + ): array { + $followingIds = DB::table('user_followers') + ->where('follower_id', $viewer->id) + ->pluck('user_id') + ->toArray(); + + if (empty($followingIds)) { + return ['data' => [], 'meta' => ['total' => 0, 'current_page' => $page, 'last_page' => 1, 'per_page' => self::PER_PAGE]]; + } + + $query = Post::with($this->eagerLoads()) + ->whereIn('user_id', $followingIds) + ->visibleTo($viewer->id) + ->orderByDesc('created_at'); + + if ($filter === 'shares') $query->where('type', Post::TYPE_ARTWORK_SHARE); + elseif ($filter === 'text') $query->where('type', Post::TYPE_TEXT); + elseif ($filter === 'uploads') $query->where('type', Post::TYPE_UPLOAD); + + $paginated = $query->paginate(self::PER_PAGE, ['*'], 'page', $page); + $diversified = $this->applyDiversityPass($paginated->getCollection()); + + return [ + 'data' => $diversified->values()->all(), + 'meta' => [ + 'total' => $paginated->total(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + ], + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Hashtag feed + // ───────────────────────────────────────────────────────────────────────── + + public function getHashtagFeed( + string $tag, + ?int $viewerId, + int $page = 1, + ): array { + $tag = mb_strtolower($tag); + + $paginated = Post::with($this->eagerLoads()) + ->whereHas('hashtags', fn ($q) => $q->where('tag', $tag)) + ->visibleTo($viewerId) + ->orderByDesc('created_at') + ->paginate(self::PER_PAGE, ['*'], 'page', $page); + + return [ + 'data' => $paginated->getCollection()->values()->all(), + 'meta' => [ + 'total' => $paginated->total(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + ], + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Saved posts feed + // ───────────────────────────────────────────────────────────────────────── + + public function getSavedFeed(User $viewer, int $page = 1): array + { + $paginated = Post::with($this->eagerLoads()) + ->whereHas('saves', fn ($q) => $q->where('user_id', $viewer->id)) + ->where('status', Post::STATUS_PUBLISHED) + ->orderByDesc('created_at') + ->paginate(self::PER_PAGE, ['*'], 'page', $page); + + return [ + 'data' => $paginated->getCollection()->values()->all(), + 'meta' => [ + 'total' => $paginated->total(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'per_page' => $paginated->perPage(), + ], + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** Eager loads for authenticated/profile feeds. */ + private function eagerLoads(): array + { + return [ + 'user', + 'user.profile', + 'targets', + 'targets.artwork', + 'targets.artwork.user', + 'targets.artwork.user.profile', + 'reactions', + 'hashtags', + ]; + } + + /** Eager loads safe for public (trending) feed calls from PostTrendingService. */ + public function publicEagerLoads(): array + { + return $this->eagerLoads(); + } + + /** + * Penalize runs of 5+ posts from the same author by deferring them to the end. + */ + public function applyDiversityPass(Collection $posts): Collection + { + $result = collect(); $deferred = collect(); $runCounts = []; + + foreach ($posts as $post) { + $uid = $post->user_id; + $runCounts[$uid] = ($runCounts[$uid] ?? 0) + 1; + ($runCounts[$uid] <= 5 ? $result : $deferred)->push($post); + } + + return $result->merge($deferred); + } + + // ───────────────────────────────────────────────────────────────────────── + // Formatter + // ───────────────────────────────────────────────────────────────────────── + + /** + * Serialize a Post into a JSON-safe array for API responses. + */ + public function formatPost(Post $post, ?int $viewerId): array + { + $artworkData = null; + + if ($post->type === Post::TYPE_ARTWORK_SHARE) { + $target = $post->targets->firstWhere('target_type', 'artwork'); + $artwork = $target?->artwork; + if ($artwork) { + $artworkData = [ + 'id' => $artwork->id, + 'title' => $artwork->title, + 'slug' => $artwork->slug, + 'thumb_url' => $artwork->thumb_url ?? null, + 'author' => [ + 'id' => $artwork->user->id, + 'username' => $artwork->user->username, + 'name' => $artwork->user->name, + 'avatar' => $artwork->user->profile?->avatar_url ?? null, + ], + ]; + } + } + + $viewerLiked = $viewerSaved = false; + if ($viewerId) { + $viewerLiked = $post->reactions->where('user_id', $viewerId)->where('reaction', 'like')->isNotEmpty(); + // saves are lazy-loaded only when needed; check if relation is loaded + if ($post->relationLoaded('saves')) { + $viewerSaved = $post->saves->where('user_id', $viewerId)->isNotEmpty(); + } else { + $viewerSaved = $post->saves()->where('user_id', $viewerId)->exists(); + } + } + + return [ + 'id' => $post->id, + 'type' => $post->type, + 'visibility' => $post->visibility, + 'status' => $post->status, + 'body' => $post->body, + 'reactions_count' => $post->reactions_count, + 'comments_count' => $post->comments_count, + 'saves_count' => $post->saves_count, + 'impressions_count'=> $post->impressions_count, + 'is_pinned' => (bool) $post->is_pinned, + 'pinned_order' => $post->pinned_order, + 'publish_at' => $post->publish_at?->toISOString(), + 'viewer_liked' => $viewerLiked, + 'viewer_saved' => $viewerSaved, + 'artwork' => $artworkData, + 'author' => [ + 'id' => $post->user->id, + 'username' => $post->user->username, + 'name' => $post->user->name, + 'avatar' => $post->user->profile?->avatar_url ?? null, + ], + 'hashtags' => $post->relationLoaded('hashtags') ? $post->hashtags->pluck('tag')->toArray() : [], + 'meta' => $post->meta, + 'created_at' => $post->created_at->toISOString(), + 'updated_at' => $post->updated_at->toISOString(), + ]; + } +} diff --git a/app/Services/Posts/PostHashtagService.php b/app/Services/Posts/PostHashtagService.php new file mode 100644 index 00000000..24b08e24 --- /dev/null +++ b/app/Services/Posts/PostHashtagService.php @@ -0,0 +1,86 @@ +parseHashtags($body); + + DB::transaction(function () use ($post, $tags) { + // Remove tags no longer in the body + PostHashtag::where('post_id', $post->id) + ->whereNotIn('tag', $tags) + ->delete(); + + // Insert new tags (ignore duplicates) + foreach ($tags as $tag) { + PostHashtag::firstOrCreate([ + 'post_id' => $post->id, + 'tag' => $tag, + ], [ + 'user_id' => $post->user_id, + 'created_at' => now(), + ]); + } + }); + } + + /** + * Trending hashtags in the last $hours hours (top $limit by post count). + * + * @return array + */ + public function trending(int $limit = 10, int $hours = 24): array + { + return DB::table('post_hashtags') + ->join('posts', 'post_hashtags.post_id', '=', 'posts.id') + ->where('post_hashtags.created_at', '>=', now()->subHours($hours)) + ->where('posts.status', Post::STATUS_PUBLISHED) + ->where('posts.visibility', Post::VISIBILITY_PUBLIC) + ->whereNull('posts.deleted_at') + ->groupBy('post_hashtags.tag') + ->orderByRaw('COUNT(*) DESC') + ->limit($limit) + ->get([ + 'post_hashtags.tag', + DB::raw('COUNT(*) as post_count'), + DB::raw('COUNT(DISTINCT post_hashtags.user_id) as author_count'), + ]) + ->map(fn ($row) => [ + 'tag' => $row->tag, + 'post_count' => (int) $row->post_count, + 'author_count' => (int) $row->author_count, + ]) + ->all(); + } +} diff --git a/app/Services/Posts/PostService.php b/app/Services/Posts/PostService.php new file mode 100644 index 00000000..69b2a151 --- /dev/null +++ b/app/Services/Posts/PostService.php @@ -0,0 +1,115 @@ +'artwork','id'=>123], ...] + * @return Post + */ + public function createPost( + User $user, + string $type, + string $visibility, + ?string $body, + array $targets = [], + ?array $linkPreview = null, + ?array $taggedUsers = null, + ?Carbon $publishAt = null, + ): Post { + $sanitizedBody = $body ? ContentSanitizer::render($body) : null; + + $status = ($publishAt && $publishAt->isFuture()) + ? Post::STATUS_SCHEDULED + : Post::STATUS_PUBLISHED; + + $meta = []; + if ($linkPreview && ! empty($linkPreview['url'])) { + $meta['link_preview'] = array_intersect_key($linkPreview, array_flip(['url', 'title', 'description', 'image', 'site_name'])); + } + if ($taggedUsers && count($taggedUsers) > 0) { + $meta['tagged_users'] = array_map( + fn ($u) => ['id' => (int) $u['id'], 'username' => (string) $u['username'], 'name' => (string) ($u['name'] ?? $u['username'])], + array_slice($taggedUsers, 0, 10), + ); + } + + return DB::transaction(function () use ($user, $type, $visibility, $sanitizedBody, $targets, $meta, $status, $publishAt) { + $post = Post::create([ + 'user_id' => $user->id, + 'type' => $type, + 'visibility' => $visibility, + 'body' => $sanitizedBody, + 'meta' => $meta ?: null, + 'status' => $status, + 'publish_at' => $publishAt, + ]); + + foreach ($targets as $target) { + PostTarget::create([ + 'post_id' => $post->id, + 'target_type' => $target['type'], + 'target_id' => $target['id'], + ]); + } + + // Sync hashtags extracted from the body + if ($sanitizedBody) { + $this->hashtags->sync($post, $sanitizedBody); + } + + return $post; + }); + } + + /** + * Update body/visibility of an existing post. + */ + public function updatePost(Post $post, ?string $body, ?string $visibility): Post + { + $updates = []; + + if ($body !== null) { + $updates['body'] = ContentSanitizer::render($body); + } + + if ($visibility !== null) { + $updates['visibility'] = $visibility; + } + + $post->update($updates); + + // Re-sync hashtags whenever body changes + if (isset($updates['body'])) { + $this->hashtags->sync($post, $updates['body']); + } + + return $post->fresh(); + } + + /** + * Soft-delete a post (cascades to targets/reactions/comments via DB). + */ + public function deletePost(Post $post): void + { + $post->delete(); + } +} diff --git a/app/Services/Posts/PostShareService.php b/app/Services/Posts/PostShareService.php new file mode 100644 index 00000000..784f34c7 --- /dev/null +++ b/app/Services/Posts/PostShareService.php @@ -0,0 +1,72 @@ +is_public || ! $artwork->is_approved) { + throw ValidationException::withMessages([ + 'artwork_id' => ['This artwork cannot be shared because it is not publicly available.'], + ]); + } + + // Duplicate share prevention: same user + same artwork within 24h + $alreadyShared = Post::where('user_id', $user->id) + ->where('type', Post::TYPE_ARTWORK_SHARE) + ->where('created_at', '>=', Carbon::now()->subHours(24)) + ->whereHas('targets', function ($q) use ($artwork) { + $q->where('target_type', 'artwork')->where('target_id', $artwork->id); + }) + ->exists(); + + if ($alreadyShared) { + throw ValidationException::withMessages([ + 'artwork_id' => ['You already shared this artwork recently. Please wait 24 hours before sharing it again.'], + ]); + } + + $sanitizedBody = $body ? ContentSanitizer::render($body) : null; + + return DB::transaction(function () use ($user, $artwork, $sanitizedBody, $visibility) { + $post = Post::create([ + 'user_id' => $user->id, + 'type' => Post::TYPE_ARTWORK_SHARE, + 'visibility' => $visibility, + 'body' => $sanitizedBody, + ]); + + PostTarget::create([ + 'post_id' => $post->id, + 'target_type' => 'artwork', + 'target_id' => $artwork->id, + ]); + + return $post; + }); + } +} diff --git a/app/Services/Posts/PostTrendingService.php b/app/Services/Posts/PostTrendingService.php new file mode 100644 index 00000000..78d592dc --- /dev/null +++ b/app/Services/Posts/PostTrendingService.php @@ -0,0 +1,146 @@ +feedService = $feedService; + } + + /** + * Return trending posts for the given viewer. + * + * @param int|null $viewerId + * @param int $page + * @param int $perPage + * @return array{data: array, meta: array} + */ + public function getTrending(?int $viewerId, int $page = 1, int $perPage = 20): array + { + $rankedIds = $this->getRankedIds(); + + // Paginate from the ranked ID list + $total = count($rankedIds); + $pageIds = array_slice($rankedIds, ($page - 1) * $perPage, $perPage); + + if (empty($pageIds)) { + return ['data' => [], 'meta' => ['total' => $total, 'current_page' => $page, 'last_page' => (int) ceil($total / $perPage) ?: 1, 'per_page' => $perPage]]; + } + + // Load posts preserving ranked order + $posts = Post::with($this->feedService->publicEagerLoads()) + ->whereIn('id', $pageIds) + ->get() + ->keyBy('id'); + + $ordered = array_filter(array_map(fn ($id) => $posts->get($id), $pageIds)); + $data = array_values(array_map( + fn ($post) => $this->feedService->formatPost($post, $viewerId), + $ordered, + )); + + return [ + 'data' => $data, + 'meta' => [ + 'total' => $total, + 'current_page' => $page, + 'last_page' => (int) ceil($total / $perPage) ?: 1, + 'per_page' => $perPage, + ], + ]; + } + + /** + * Get or compute the ranked post-ID list from cache. + * + * @return int[] + */ + public function getRankedIds(): array + { + return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () { + return $this->computeRankedIds(); + }); + } + + /** Force a cache refresh (called by the CLI command). */ + public function refresh(): array + { + Cache::forget(self::CACHE_KEY); + return $this->getRankedIds(); + } + + // ───────────────────────────────────────────────────────────────────────── + + private function computeRankedIds(): array + { + $cutoff = now()->subDays(self::WINDOW_DAYS); + + $rows = DB::table('posts') + ->leftJoin( + DB::raw('(SELECT post_id, COUNT(*) as unique_reactors FROM post_reactions GROUP BY post_id) pr'), + 'posts.id', '=', 'pr.post_id', + ) + ->where('posts.status', Post::STATUS_PUBLISHED) + ->where('posts.visibility', Post::VISIBILITY_PUBLIC) + ->where('posts.created_at', '>=', $cutoff) + ->whereNull('posts.deleted_at') + ->select([ + 'posts.id', + 'posts.user_id', + 'posts.reactions_count', + 'posts.comments_count', + 'posts.created_at', + DB::raw('COALESCE(pr.unique_reactors, 0) as unique_reactors'), + ]) + ->get(); + + $now = now()->timestamp; + + $scored = $rows->map(function ($row) use ($now) { + $hoursSince = ($now - strtotime($row->created_at)) / 3600; + $base = ($row->reactions_count * 3) + + ($row->comments_count * 5) + + ($row->unique_reactors * 4); + $score = $base * exp(-$hoursSince / 24); + + return ['id' => $row->id, 'user_id' => $row->user_id, 'score' => $score]; + })->sortByDesc('score'); + + // Apply author diversity: max MAX_PER_AUTHOR posts per author + $authorCount = []; + $result = []; + + foreach ($scored as $item) { + $uid = $item['user_id']; + $authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1; + if ($authorCount[$uid] <= self::MAX_PER_AUTHOR) { + $result[] = $item['id']; + } + } + + return $result; + } +} diff --git a/app/Services/Studio/StudioArtworkQueryService.php b/app/Services/Studio/StudioArtworkQueryService.php index 3e87139d..6cecdb31 100644 --- a/app/Services/Studio/StudioArtworkQueryService.php +++ b/app/Services/Studio/StudioArtworkQueryService.php @@ -29,20 +29,31 @@ final class StudioArtworkQueryService */ public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator { - // Skip Meilisearch when driver is null (e.g. in tests) + // Studio is a management dashboard — DB is always authoritative. + // Draft/unpublished artworks are never indexed in Meilisearch, so using + // Meili as the primary source would silently hide them. + // + // Meilisearch is only used when the user submits a free-text query (`q`), + // since it can provide relevance-ranked full-text search across many docs. + // Even then, we fall back to DB on any Meili error. + + $hasTextQuery = !empty($filters['q']); $driver = config('scout.driver'); - if (empty($driver) || $driver === 'null') { - return $this->listViaDatabase($userId, $filters, $perPage); + $useMeili = $hasTextQuery && !empty($driver) && $driver !== 'null'; + + if ($useMeili) { + try { + return $this->listViaMeilisearch($userId, $filters, $perPage); + } catch (\Throwable $e) { + Log::warning('Studio: Meilisearch unavailable during text search, falling back to DB', [ + 'error' => $e->getMessage(), + 'user_id' => $userId, + ]); + // fall through to DB + } } - try { - return $this->listViaMeilisearch($userId, $filters, $perPage); - } catch (\Throwable $e) { - Log::warning('Studio: Meilisearch unavailable, falling back to DB', [ - 'error' => $e->getMessage(), - ]); - return $this->listViaDatabase($userId, $filters, $perPage); - } + return $this->listViaDatabase($userId, $filters, $perPage); } private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator diff --git a/app/Services/Uploads/UploadDerivativesService.php b/app/Services/Uploads/UploadDerivativesService.php index 70b6c059..b8ef8343 100644 --- a/app/Services/Uploads/UploadDerivativesService.php +++ b/app/Services/Uploads/UploadDerivativesService.php @@ -27,34 +27,81 @@ final class UploadDerivativesService } } - public function storeOriginal(string $sourcePath, string $hash): string + public function storeOriginal(string $sourcePath, string $hash, ?string $originalFileName = null): string { $this->assertImageAvailable(); + // Preserve original file extension and store with filename = . + $dir = $this->storage->ensureHashDirectory('original', $hash); - $dir = $this->storage->ensureHashDirectory('originals', $hash); - $target = $dir . DIRECTORY_SEPARATOR . 'orig.webp'; - $quality = (int) config('uploads.quality', 85); + $origExt = $this->resolveOriginalExtension($sourcePath, $originalFileName); + $target = $dir . DIRECTORY_SEPARATOR . $hash . ($origExt !== '' ? '.' . $origExt : ''); - /** @var InterventionImageInterface $img */ - $img = $this->manager->read($sourcePath); - $encoder = new \Intervention\Image\Encoders\WebpEncoder($quality); - $encoded = (string) $img->encode($encoder); - File::put($target, $encoded); + // Try a direct copy first (works for images and archives). If that fails, + // fall back to re-encoding image to webp as a last resort. + try { + if (! File::copy($sourcePath, $target)) { + throw new \RuntimeException('Copy failed'); + } + } catch (\Throwable $e) { + // Fallback: encode to webp + $quality = (int) config('uploads.quality', 85); + /** @var InterventionImageInterface $img */ + $img = $this->manager->read($sourcePath); + $encoder = new \Intervention\Image\Encoders\WebpEncoder($quality); + $encoded = (string) $img->encode($encoder); + $target = $dir . DIRECTORY_SEPARATOR . $hash . '.webp'; + File::put($target, $encoded); + } return $target; } + private function resolveOriginalExtension(string $sourcePath, ?string $originalFileName): string + { + $fromClientName = strtolower((string) pathinfo((string) $originalFileName, PATHINFO_EXTENSION)); + if ($fromClientName !== '' && preg_match('/^[a-z0-9]{1,12}$/', $fromClientName) === 1) { + return $fromClientName; + } + + $fromSource = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION)); + if ($fromSource !== '' && $fromSource !== 'upload' && preg_match('/^[a-z0-9]{1,12}$/', $fromSource) === 1) { + return $fromSource; + } + + $mime = File::exists($sourcePath) ? (string) (File::mimeType($sourcePath) ?? '') : ''; + return $this->extensionFromMime($mime); + } + + private function extensionFromMime(string $mime): string + { + return match (strtolower($mime)) { + 'image/jpeg', 'image/jpg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + 'image/bmp' => 'bmp', + 'image/tiff' => 'tif', + 'application/zip', 'application/x-zip-compressed' => 'zip', + 'application/x-rar-compressed', 'application/vnd.rar' => 'rar', + 'application/x-7z-compressed' => '7z', + 'application/x-tar' => 'tar', + 'application/gzip', 'application/x-gzip' => 'gz', + default => 'bin', + }; + } + public function generatePublicDerivatives(string $sourcePath, string $hash): array { $this->assertImageAvailable(); $quality = (int) config('uploads.quality', 85); $variants = (array) config('uploads.derivatives', []); - $dir = $this->storage->publicHashDirectory($hash); $written = []; foreach ($variants as $variant => $options) { $variant = (string) $variant; - $path = $dir . DIRECTORY_SEPARATOR . $variant . '.webp'; + $dir = $this->storage->ensureHashDirectory($variant, $hash); + // store derivative filename as .webp per variant directory + $path = $dir . DIRECTORY_SEPARATOR . $hash . '.webp'; /** @var InterventionImageInterface $img */ $img = $this->manager->read($sourcePath); diff --git a/app/Services/Uploads/UploadPipelineService.php b/app/Services/Uploads/UploadPipelineService.php index 83446042..5ef1844e 100644 --- a/app/Services/Uploads/UploadPipelineService.php +++ b/app/Services/Uploads/UploadPipelineService.php @@ -104,20 +104,22 @@ final class UploadPipelineService return $result; } - public function processAndPublish(string $sessionId, string $hash, int $artworkId): array + public function processAndPublish(string $sessionId, string $hash, int $artworkId, ?string $originalFileName = null): array { $session = $this->sessions->getOrFail($sessionId); - $originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash); - $originalRelative = $this->storage->sectionRelativePath('originals', $hash, 'orig.webp'); - $this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath)); + $originalPath = $this->derivatives->storeOriginal($session->tempPath, $hash, $originalFileName); + $origFilename = basename($originalPath); + $originalRelative = $this->storage->sectionRelativePath('original', $hash, $origFilename); + $origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream'; + $this->artworkFiles->upsert($artworkId, 'orig', $originalRelative, $origMime, (int) filesize($originalPath)); $publicAbsolute = $this->derivatives->generatePublicDerivatives($session->tempPath, $hash); $publicRelative = []; foreach ($publicAbsolute as $variant => $absolutePath) { - $filename = $variant . '.webp'; - $relativePath = $this->storage->publicRelativePath($hash, $filename); + $filename = $hash . '.webp'; + $relativePath = $this->storage->sectionRelativePath($variant, $hash, $filename); $this->artworkFiles->upsert($artworkId, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath)); $publicRelative[$variant] = $relativePath; } @@ -126,13 +128,14 @@ final class UploadPipelineService $width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : 1; $height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1; + $origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: ''); Artwork::query()->whereKey($artworkId)->update([ - 'file_name' => basename($originalRelative), + 'file_name' => $origFilename, 'file_path' => '', 'file_size' => (int) filesize($originalPath), - 'mime_type' => 'image/webp', + 'mime_type' => $origMime, 'hash' => $hash, - 'file_ext' => 'webp', + 'file_ext' => $origExt, 'thumb_ext' => 'webp', 'width' => max(1, $width), 'height' => max(1, $height), @@ -152,6 +155,11 @@ final class UploadPipelineService ]; } + public function originalHashExists(string $hash): bool + { + return $this->storage->originalHashExists($hash); + } + private function quarantine(UploadSessionData $session, string $reason): void { $newPath = $this->storage->moveToSection($session->tempPath, 'quarantine'); diff --git a/app/Services/Uploads/UploadStorageService.php b/app/Services/Uploads/UploadStorageService.php index 3d035f3b..a7e450c4 100644 --- a/app/Services/Uploads/UploadStorageService.php +++ b/app/Services/Uploads/UploadStorageService.php @@ -76,33 +76,6 @@ final class UploadStorageService return $dir; } - public function publicHashDirectory(string $hash): string - { - $prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR); - $base = $this->sectionPath('public') . DIRECTORY_SEPARATOR . $prefix; - - if (! File::exists($base)) { - File::makeDirectory($base, 0755, true); - } - - $segments = $this->hashSegments($hash); - $dir = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments); - - if (! File::exists($dir)) { - File::makeDirectory($dir, 0755, true); - } - - return $dir; - } - - public function publicRelativePath(string $hash, string $filename): string - { - $prefix = trim((string) config('uploads.public_img_prefix', 'img'), DIRECTORY_SEPARATOR); - $segments = $this->hashSegments($hash); - - return $prefix . '/' . implode('/', $segments) . '/' . ltrim($filename, '/'); - } - public function sectionRelativePath(string $section, string $hash, string $filename): string { $segments = $this->hashSegments($hash); @@ -111,6 +84,24 @@ final class UploadStorageService return $section . '/' . implode('/', $segments) . '/' . ltrim($filename, '/'); } + public function originalHashExists(string $hash): bool + { + $segments = $this->hashSegments($hash); + $dir = $this->sectionPath('original') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments); + + if (! File::isDirectory($dir)) { + return false; + } + + $normalizedHash = strtolower(preg_replace('/[^a-z0-9]/', '', $hash) ?? ''); + if ($normalizedHash === '') { + return false; + } + + $matches = File::glob($dir . DIRECTORY_SEPARATOR . $normalizedHash . '.*'); + return is_array($matches) && count($matches) > 0; + } + private function safeExtension(UploadedFile $file): string { $extension = (string) $file->guessExtension(); @@ -125,10 +116,11 @@ final class UploadStorageService $hash = preg_replace('/[^a-z0-9]/', '', $hash) ?? ''; $hash = str_pad($hash, 6, '0'); + // Use two 2-char segments for directory sharding: first two chars, next two chars. + // Result:
/// $segments = [ substr($hash, 0, 2), substr($hash, 2, 2), - substr($hash, 4, 2), ]; return array_map(static fn (string $part): string => $part === '' ? '00' : $part, $segments); diff --git a/config/uploads.php b/config/uploads.php index 18516f9a..7e6bcc76 100644 --- a/config/uploads.php +++ b/config/uploads.php @@ -6,14 +6,16 @@ return [ 'storage_root' => env('SKINBASE_STORAGE_ROOT', storage_path('app/artworks')), 'paths' => [ - 'tmp' => 'tmp', + 'tmp' => 'tmp', 'quarantine' => 'quarantine', - 'originals' => 'originals', - 'public' => 'public', + 'original' => 'original', + 'xs' => 'xs', + 'sm' => 'sm', + 'md' => 'md', + 'lg' => 'lg', + 'xl' => 'xl', ], - 'public_img_prefix' => 'img', - 'max_size_mb' => 50, 'max_pixels' => 12000, @@ -26,8 +28,8 @@ return [ 'allow_gif' => env('UPLOAD_ALLOW_GIF', false), 'derivatives' => [ - 'thumb' => ['max' => 320], - 'sq' => ['size' => 512], + 'xs' => ['max' => 320], + 'sm' => ['max' => 680], 'md' => ['max' => 1024], 'lg' => ['max' => 1920], 'xl' => ['max' => 2560], diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php new file mode 100644 index 00000000..3307c778 --- /dev/null +++ b/database/factories/PostFactory.php @@ -0,0 +1,44 @@ + + */ +class PostFactory extends Factory +{ + protected $model = Post::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'type' => Post::TYPE_TEXT, + 'visibility' => Post::VISIBILITY_PUBLIC, + 'status' => Post::STATUS_PUBLISHED, + 'body' => fake()->paragraph(), + 'meta' => null, + 'reactions_count' => 0, + 'comments_count' => 0, + ]; + } + + public function artworkShare(): static + { + return $this->state(['type' => Post::TYPE_ARTWORK_SHARE]); + } + + public function followersOnly(): static + { + return $this->state(['visibility' => Post::VISIBILITY_FOLLOWERS]); + } + + public function private(): static + { + return $this->state(['visibility' => Post::VISIBILITY_PRIVATE]); + } +} diff --git a/database/migrations/2026_03_01_100001_add_scheduled_publish_to_artworks_table.php b/database/migrations/2026_03_01_100001_add_scheduled_publish_to_artworks_table.php new file mode 100644 index 00000000..eb1d6d94 --- /dev/null +++ b/database/migrations/2026_03_01_100001_add_scheduled_publish_to_artworks_table.php @@ -0,0 +1,50 @@ +dateTime('publish_at')->nullable()->after('published_at'); + + // Lifecycle status + $table->string('artwork_status', 20)->default('draft')->after('publish_at'); + + // User's display timezone (IANA), stored for UX display only + $table->string('artwork_timezone', 50)->nullable()->after('artwork_status'); + + // Index for scheduler job: find artworks whose publish_at has passed + $table->index(['artwork_status', 'publish_at'], 'idx_artworks_scheduled_publish'); + }); + } + + public function down(): void + { + Schema::table('artworks', function (Blueprint $table) { + $table->dropIndex('idx_artworks_scheduled_publish'); + $table->dropColumn(['publish_at', 'artwork_status', 'artwork_timezone']); + }); + } +}; diff --git a/database/migrations/2026_03_02_000001_create_posts_table.php b/database/migrations/2026_03_02_000001_create_posts_table.php new file mode 100644 index 00000000..80ac5074 --- /dev/null +++ b/database/migrations/2026_03_02_000001_create_posts_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type', 32)->default('text'); // text | artwork_share | upload | achievement + $table->string('visibility', 16)->default('public'); // public | followers | private + $table->longText('body')->nullable(); + $table->json('meta')->nullable(); + $table->unsignedInteger('reactions_count')->default(0); + $table->unsignedInteger('comments_count')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'created_at']); + $table->index(['type', 'created_at']); + $table->index(['visibility', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/database/migrations/2026_03_02_000002_create_post_targets_table.php b/database/migrations/2026_03_02_000002_create_post_targets_table.php new file mode 100644 index 00000000..2b43f651 --- /dev/null +++ b/database/migrations/2026_03_02_000002_create_post_targets_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->string('target_type', 32); // artwork | collection + $table->unsignedBigInteger('target_id'); + $table->timestamp('created_at')->useCurrent(); + + $table->index('post_id'); + $table->index(['target_type', 'target_id']); + $table->unique(['post_id', 'target_type', 'target_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_targets'); + } +}; diff --git a/database/migrations/2026_03_02_000003_create_post_reactions_table.php b/database/migrations/2026_03_02_000003_create_post_reactions_table.php new file mode 100644 index 00000000..66b9497a --- /dev/null +++ b/database/migrations/2026_03_02_000003_create_post_reactions_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('reaction', 16)->default('like'); + $table->timestamps(); + + $table->index('post_id'); + $table->index('user_id'); + $table->unique(['post_id', 'user_id', 'reaction']); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_reactions'); + } +}; diff --git a/database/migrations/2026_03_02_000004_create_post_comments_table.php b/database/migrations/2026_03_02_000004_create_post_comments_table.php new file mode 100644 index 00000000..f95d3d53 --- /dev/null +++ b/database/migrations/2026_03_02_000004_create_post_comments_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('body'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['post_id', 'created_at']); + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_comments'); + } +}; diff --git a/database/migrations/2026_03_02_000005_create_post_saves_table.php b/database/migrations/2026_03_02_000005_create_post_saves_table.php new file mode 100644 index 00000000..7fe87ed1 --- /dev/null +++ b/database/migrations/2026_03_02_000005_create_post_saves_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + $table->index('post_id'); + $table->index('user_id'); + $table->unique(['post_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_saves'); + } +}; diff --git a/database/migrations/2026_03_02_000006_create_post_reports_table.php b/database/migrations/2026_03_02_000006_create_post_reports_table.php new file mode 100644 index 00000000..9a26dc74 --- /dev/null +++ b/database/migrations/2026_03_02_000006_create_post_reports_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('reporter_user_id')->constrained('users')->cascadeOnDelete(); + $table->string('reason', 64); + $table->text('message')->nullable(); + $table->string('status', 16)->default('open'); // open | reviewed | actioned + $table->timestamps(); + + $table->index('post_id'); + $table->index('reporter_user_id'); + $table->index('status'); + $table->unique(['post_id', 'reporter_user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_reports'); + } +}; diff --git a/database/migrations/2026_03_02_100001_add_feed_v2_columns_to_posts_table.php b/database/migrations/2026_03_02_100001_add_feed_v2_columns_to_posts_table.php new file mode 100644 index 00000000..0cb75b64 --- /dev/null +++ b/database/migrations/2026_03_02_100001_add_feed_v2_columns_to_posts_table.php @@ -0,0 +1,41 @@ +boolean('is_pinned')->default(false)->after('meta'); + $table->unsignedTinyInteger('pinned_order')->nullable()->after('is_pinned'); + + // Scheduled posts + $table->timestamp('publish_at')->nullable()->after('pinned_order'); + $table->string('status', 16)->default('published')->after('publish_at'); // draft | scheduled | published + + // Analytics + $table->unsignedBigInteger('impressions_count')->default(0)->after('comments_count'); + $table->float('engagement_score')->default(0.0)->after('impressions_count'); + $table->unsignedInteger('saves_count')->default(0)->after('engagement_score'); + + $table->index(['is_pinned', 'user_id', 'pinned_order']); + $table->index(['status', 'publish_at']); + $table->index(['status', 'created_at']); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropIndex(['is_pinned', 'user_id', 'pinned_order']); + $table->dropIndex(['status', 'publish_at']); + $table->dropIndex(['status', 'created_at']); + $table->dropColumn(['is_pinned', 'pinned_order', 'publish_at', 'status', + 'impressions_count', 'engagement_score', 'saves_count']); + }); + } +}; diff --git a/database/migrations/2026_03_02_100002_create_post_hashtags_table.php b/database/migrations/2026_03_02_100002_create_post_hashtags_table.php new file mode 100644 index 00000000..941cd934 --- /dev/null +++ b/database/migrations/2026_03_02_100002_create_post_hashtags_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); // denormalised for fast author diversity queries + $table->string('tag', 64)->index(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['tag', 'created_at']); // trending query + $table->index(['post_id', 'tag']); + $table->unique(['post_id', 'tag']); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_hashtags'); + } +}; diff --git a/database/migrations/2026_03_02_100003_add_is_highlighted_to_post_comments_table.php b/database/migrations/2026_03_02_100003_add_is_highlighted_to_post_comments_table.php new file mode 100644 index 00000000..3dc71b7e --- /dev/null +++ b/database/migrations/2026_03_02_100003_add_is_highlighted_to_post_comments_table.php @@ -0,0 +1,24 @@ +boolean('is_highlighted')->default(false)->after('body'); + $table->index(['post_id', 'is_highlighted']); + }); + } + + public function down(): void + { + Schema::table('post_comments', function (Blueprint $table) { + $table->dropIndex(['post_id', 'is_highlighted']); + $table->dropColumn('is_highlighted'); + }); + } +}; diff --git a/database/migrations/2026_03_02_100004_add_auto_post_upload_to_user_profiles_table.php b/database/migrations/2026_03_02_100004_add_auto_post_upload_to_user_profiles_table.php new file mode 100644 index 00000000..d74482ba --- /dev/null +++ b/database/migrations/2026_03_02_100004_add_auto_post_upload_to_user_profiles_table.php @@ -0,0 +1,22 @@ +boolean('auto_post_upload')->default(true)->after('website'); + }); + } + + public function down(): void + { + Schema::table('user_profiles', function (Blueprint $table) { + $table->dropColumn('auto_post_upload'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 606beb2a..f94c606f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,28 @@ "@emoji-mart/react": "^1.1.1", "@inertiajs/core": "^1.0.4", "@inertiajs/react": "^1.0.4", + "@tiptap/extension-code-block-lowlight": "^3.20.0", + "@tiptap/extension-image": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-mention": "^3.20.0", + "@tiptap/extension-placeholder": "^3.20.0", + "@tiptap/extension-underline": "^3.20.0", + "@tiptap/react": "^3.20.0", + "@tiptap/starter-kit": "^3.20.0", + "@tiptap/suggestion": "^3.20.0", "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", + "lowlight": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-markdown": "^10.1.0" + "react-markdown": "^10.1.0", + "tippy.js": "^6.3.7" }, "devDependencies": { "@playwright/test": "^1.40.0", "@tailwindcss/forms": "^0.5.2", "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "alpinejs": "^3.4.2", @@ -61,6 +73,31 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -644,6 +681,34 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, "node_modules/@inertiajs/core": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-1.3.0.tgz", @@ -1083,6 +1148,22 @@ "node": ">=18" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1732,6 +1813,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", @@ -1774,6 +1875,519 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tiptap/core": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", + "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz", + "integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz", + "integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.0.tgz", + "integrity": "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz", + "integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz", + "integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz", + "integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-code-block-lowlight": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.20.0.tgz", + "integrity": "sha512-9lN9rn07lOWkLnByT5C1axtq56MHpOI7MpLaCmX3p+x1bDl6Uvixm6AoBdTLfZUmUYeEFBsf7t5cR+QepMbkiA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/extension-code-block": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "highlight.js": "^11", + "lowlight": "^2 || ^3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz", + "integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz", + "integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.0.tgz", + "integrity": "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz", + "integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz", + "integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz", + "integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz", + "integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.0.tgz", + "integrity": "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz", + "integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz", + "integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz", + "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz", + "integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz", + "integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-mention": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.20.0.tgz", + "integrity": "sha512-wUjsq7Za0JJdJzrGNG+g8nrCpek/85GQ0Rm9bka3PynIVRwus+xQqW6IyWVPBdl1BSkrbgMAUqtrfoh1ymznbg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@tiptap/suggestion": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz", + "integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz", + "integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.20.0.tgz", + "integrity": "sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz", + "integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz", + "integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz", + "integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz", + "integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", + "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.0.tgz", + "integrity": "sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.20.0", + "@tiptap/extension-floating-menu": "^3.20.0" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz", + "integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/extension-blockquote": "^3.20.0", + "@tiptap/extension-bold": "^3.20.0", + "@tiptap/extension-bullet-list": "^3.20.0", + "@tiptap/extension-code": "^3.20.0", + "@tiptap/extension-code-block": "^3.20.0", + "@tiptap/extension-document": "^3.20.0", + "@tiptap/extension-dropcursor": "^3.20.0", + "@tiptap/extension-gapcursor": "^3.20.0", + "@tiptap/extension-hard-break": "^3.20.0", + "@tiptap/extension-heading": "^3.20.0", + "@tiptap/extension-horizontal-rule": "^3.20.0", + "@tiptap/extension-italic": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-list": "^3.20.0", + "@tiptap/extension-list-item": "^3.20.0", + "@tiptap/extension-list-keymap": "^3.20.0", + "@tiptap/extension-ordered-list": "^3.20.0", + "@tiptap/extension-paragraph": "^3.20.0", + "@tiptap/extension-strike": "^3.20.0", + "@tiptap/extension-text": "^3.20.0", + "@tiptap/extension-underline": "^3.20.0", + "@tiptap/extensions": "^3.20.0", + "@tiptap/pm": "^3.20.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.0.tgz", + "integrity": "sha512-OA9Fe+1Q/Ex0ivTcpRcVFiLnNsVdIBmiEoctt/gu4H2ayCYmZ906veioXNdc1m/3MtVVUIuEnvwwsrOZXlfDEw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1807,6 +2421,22 @@ "@types/unist": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1816,6 +2446,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1828,6 +2464,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2024,6 +2666,22 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2453,6 +3111,12 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2612,6 +3276,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2777,6 +3448,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -2813,6 +3496,15 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3153,6 +3845,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3375,6 +4076,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", @@ -3717,6 +4425,21 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -3741,6 +4464,21 @@ "dev": true, "license": "MIT" }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -3748,6 +4486,16 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3758,6 +4506,35 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3920,6 +4697,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4552,6 +5335,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -4864,6 +5653,34 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -4874,6 +5691,201 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", + "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", + "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", + "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", + "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4890,6 +5902,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -4947,6 +5968,13 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -5118,6 +6146,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -5661,6 +6695,15 @@ "node": ">=14.0.0" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -5763,6 +6806,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -5881,6 +6930,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7111,6 +8169,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 727e73eb..c0c3582e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@playwright/test": "^1.40.0", "@tailwindcss/forms": "^0.5.2", "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "alpinejs": "^3.4.2", @@ -35,10 +36,21 @@ "@emoji-mart/react": "^1.1.1", "@inertiajs/core": "^1.0.4", "@inertiajs/react": "^1.0.4", + "@tiptap/extension-code-block-lowlight": "^3.20.0", + "@tiptap/extension-image": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-mention": "^3.20.0", + "@tiptap/extension-placeholder": "^3.20.0", + "@tiptap/extension-underline": "^3.20.0", + "@tiptap/react": "^3.20.0", + "@tiptap/starter-kit": "^3.20.0", + "@tiptap/suggestion": "^3.20.0", "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", + "lowlight": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-markdown": "^10.1.0" + "react-markdown": "^10.1.0", + "tippy.js": "^6.3.7" } } diff --git a/resources/css/app.css b/resources/css/app.css index bfafb03c..5d0ed804 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -87,3 +87,107 @@ } } +/* ─── TipTap rich text editor ─── */ +.tiptap { + outline: none; +} + +.tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: theme('colors.zinc.600'); + pointer-events: none; + height: 0; +} + +.tiptap img { + max-width: 100%; + height: auto; + border-radius: 0.75rem; +} + +.tiptap pre { + background: theme('colors.white / 4%'); + border: 1px solid theme('colors.white / 6%'); + border-radius: 0.75rem; + padding: 0.75rem 1rem; + overflow-x: auto; +} + +.tiptap pre code { + background: none; + border: none; + padding: 0; + font-size: 0.8125rem; + color: theme('colors.zinc.300'); +} + +.tiptap blockquote { + border-left: 3px solid theme('colors.sky.500 / 40%'); + padding-left: 1rem; + margin-left: 0; + color: theme('colors.zinc.400'); + font-style: italic; +} + +.tiptap hr { + border: none; + border-top: 1px solid theme('colors.white / 10%'); + margin: 1.5rem 0; +} + +.tiptap ul { + list-style-type: disc; + padding-left: 1.5rem; +} + +.tiptap ol { + list-style-type: decimal; + padding-left: 1.5rem; +} + +.tiptap li { + margin-bottom: 0.25rem; +} + +.tiptap a { + color: theme('colors.sky.300'); + text-decoration: underline; + text-underline-offset: 2px; +} + +.tiptap a:hover { + color: theme('colors.sky.200'); +} + +/* ─── @mention pills ─── */ +.tiptap .mention, +.mention { + background: theme('colors.sky.500 / 15%'); + color: theme('colors.sky.300'); + border-radius: 0.375rem; + padding: 0.125rem 0.375rem; + font-weight: 500; + font-size: 0.875rem; + text-decoration: none; + cursor: pointer; + white-space: nowrap; +} + +.tiptap .mention:hover { + background: theme('colors.sky.500 / 25%'); + color: theme('colors.sky.200'); +} + +/* ─── Tippy.js mention dropdown theme ─── */ +.tippy-box[data-theme~='mention'] { + background: transparent; + border: none; + padding: 0; + box-shadow: none; +} + +.tippy-box[data-theme~='mention'] .tippy-content { + padding: 0; +} + diff --git a/resources/js/Pages/Feed/FollowingFeed.jsx b/resources/js/Pages/Feed/FollowingFeed.jsx new file mode 100644 index 00000000..73592f62 --- /dev/null +++ b/resources/js/Pages/Feed/FollowingFeed.jsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { usePage } from '@inertiajs/react' +import axios from 'axios' +import PostCard from '../../Components/Feed/PostCard' +import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton' + +const FILTER_OPTIONS = [ + { value: 'all', label: 'All' }, + { value: 'shares', label: 'Artwork Shares' }, + { value: 'uploads', label: 'New Uploads' }, + { value: 'text', label: 'Text Posts' }, +] + +function EmptyFollowingState() { + return ( +
+
+ +
+

Nothing here yet

+

+ Follow some creators to see their posts here. Discover amazing artwork on{' '} + Trending. +

+
+ ) +} + +export default function FollowingFeed() { + const { props } = usePage() + const { auth } = props + const authUser = auth?.user ?? null + + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [loaded, setLoaded] = useState(false) + const [filter, setFilter] = useState('all') + + const fetchFeed = useCallback(async (p = 1, f = filter) => { + setLoading(true) + try { + const { data } = await axios.get('/api/posts/following', { + params: { page: p, filter: f }, + }) + setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data]) + setHasMore(data.meta.current_page < data.meta.last_page) + setPage(p) + } catch { + // + } finally { + setLoading(false) + setLoaded(true) + } + }, [filter]) + + useEffect(() => { + fetchFeed(1, filter) + }, [filter]) + + const handleFilterChange = (f) => { + if (f === filter) return + setFilter(f) + setPosts([]) + setLoaded(false) + setPage(1) + } + + const handleDeleted = useCallback((postId) => { + setPosts((prev) => prev.filter((p) => p.id !== postId)) + }, []) + + return ( +
+ {/* ── Page header ────────────────────────────────────────────────────── */} +
+
+
+

+ + Following Feed +

+

Posts from creators you follow

+
+ + Discover creators → + +
+ + {/* Filter chips */} +
+ {FILTER_OPTIONS.map((f) => ( + + ))} +
+
+ + {/* ── Feed ────────────────────────────────────────────────────────────── */} +
+ {/* Loading skeletons */} + {!loaded && loading && ( + <> + + + + + )} + + {/* Empty */} + {loaded && !loading && posts.length === 0 && } + + {/* Posts */} + {posts.map((post) => ( + + ))} + + {/* Load more */} + {loaded && hasMore && ( +
+ +
+ )} +
+
+ ) +} diff --git a/resources/js/Pages/Feed/HashtagFeed.jsx b/resources/js/Pages/Feed/HashtagFeed.jsx new file mode 100644 index 00000000..e07b06e0 --- /dev/null +++ b/resources/js/Pages/Feed/HashtagFeed.jsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { usePage } from '@inertiajs/react' +import axios from 'axios' +import PostCard from '../../Components/Feed/PostCard' +import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton' + +export default function HashtagFeed() { + const { props } = usePage() + const { auth, tag } = props + const authUser = auth?.user ?? null + + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [loaded, setLoaded] = useState(false) + const [totalPosts, setTotalPosts] = useState(null) + + const fetchFeed = useCallback(async (p = 1) => { + setLoading(true) + try { + const { data } = await axios.get(`/api/feed/hashtag/${encodeURIComponent(tag)}`, { + params: { page: p }, + }) + setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data]) + setHasMore(data.meta.current_page < data.meta.last_page) + setTotalPosts(data.meta.total ?? null) + setPage(p) + } catch { + // + } finally { + setLoading(false) + setLoaded(true) + } + }, [tag]) + + useEffect(() => { fetchFeed(1) }, [tag]) + + const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), []) + + return ( +
+
+ {/* Header */} +
+
+ + # + +
+

#{tag}

+ {totalPosts !== null && ( +

+ {totalPosts.toLocaleString()} post{totalPosts !== 1 ? 's' : ''} +

+ )} +
+
+ +
+ + {/* Feed */} +
+ {!loaded && loading && ( + <>{Array.from({ length: 3 }).map((_, i) => )} + )} + + {loaded && !loading && posts.length === 0 && ( +
+
+ +
+

No posts yet

+

+ No posts tagged #{tag} yet. Be the first! +

+
+ )} + + {posts.map((post) => ( + + ))} + + {loaded && hasMore && ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/resources/js/Pages/Feed/SavedFeed.jsx b/resources/js/Pages/Feed/SavedFeed.jsx new file mode 100644 index 00000000..c0f8bcae --- /dev/null +++ b/resources/js/Pages/Feed/SavedFeed.jsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { usePage } from '@inertiajs/react' +import axios from 'axios' +import PostCard from '../../Components/Feed/PostCard' +import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton' + +export default function SavedFeed() { + const { props } = usePage() + const { auth } = props + const authUser = auth?.user ?? null + + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [loaded, setLoaded] = useState(false) + + const fetchFeed = useCallback(async (p = 1) => { + setLoading(true) + try { + const { data } = await axios.get('/api/posts/saved', { params: { page: p } }) + setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data]) + setHasMore(data.meta.current_page < data.meta.last_page) + setPage(p) + } catch { + // + } finally { + setLoading(false) + setLoaded(true) + } + }, []) + + useEffect(() => { fetchFeed(1) }, []) + + const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), []) + + // When a post is unsaved, remove it from the list too + const handleUnsaved = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), []) + + return ( +
+
+ {/* Header */} +
+

+ + Saved Posts +

+

Posts you've bookmarked

+
+ + {/* Feed */} +
+ {!loaded && loading && ( + <>{Array.from({ length: 3 }).map((_, i) => )} + )} + + {loaded && !loading && posts.length === 0 && ( +
+
+ +
+

Nothing saved yet

+

+ Bookmark posts to read later. Look for the{' '} + icon on any post. +

+ + Browse trending posts → + +
+ )} + + {posts.map((post) => ( + + ))} + + {loaded && hasMore && ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/resources/js/Pages/Feed/SearchFeed.jsx b/resources/js/Pages/Feed/SearchFeed.jsx new file mode 100644 index 00000000..38d16c87 --- /dev/null +++ b/resources/js/Pages/Feed/SearchFeed.jsx @@ -0,0 +1,255 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { usePage } from '@inertiajs/react' +import axios from 'axios' +import PostCard from '../../Components/Feed/PostCard' +import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton' + +/* ── Trending hashtags sidebar ─────────────────────────────────────────────── */ +function TrendingHashtagsSidebar({ hashtags }) { + if (!hashtags || hashtags.length === 0) return null + return ( +
+
+ + + Trending Tags + +
+
+ {hashtags.map((h) => ( + + #{h.tag} + + {h.post_count} + + + ))} +
+
+ ) +} + +/* ── Main page ─────────────────────────────────────────────────────────────── */ +export default function SearchFeed() { + const { props } = usePage() + const { auth, initialQuery, trendingHashtags } = props + const authUser = auth?.user ?? null + + const [query, setQuery] = useState(initialQuery ?? '') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) + const [meta, setMeta] = useState(null) + const [page, setPage] = useState(1) + + const debounceRef = useRef(null) + const inputRef = useRef(null) + + /* ── Push query into URL without reload ──────────────────────────────────── */ + const pushUrl = useCallback((q) => { + const url = q.trim() + ? `/feed/search?q=${encodeURIComponent(q.trim())}` + : '/feed/search' + window.history.replaceState({}, '', url) + }, []) + + /* ── Fetch results ───────────────────────────────────────────────────────── */ + const fetchResults = useCallback(async (q, p = 1) => { + if (!q.trim() || q.trim().length < 2) { + setResults([]) + setMeta(null) + setSearched(false) + return + } + setLoading(true) + try { + const { data } = await axios.get('/api/feed/search', { + params: { q: q.trim(), page: p }, + }) + setResults((prev) => p === 1 ? data.data : [...prev, ...data.data]) + setMeta(data.meta) + setPage(p) + setSearched(true) + } catch { + // + } finally { + setLoading(false) + } + }, []) + + /* ── Debounce typing ─────────────────────────────────────────────────────── */ + const handleChange = useCallback((e) => { + const q = e.target.value + setQuery(q) + pushUrl(q) + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + fetchResults(q, 1) + }, 350) + }, [fetchResults, pushUrl]) + + const handleSubmit = useCallback((e) => { + e.preventDefault() + clearTimeout(debounceRef.current) + fetchResults(query, 1) + }, [fetchResults, query]) + + const handleClear = useCallback(() => { + setQuery('') + setResults([]) + setMeta(null) + setSearched(false) + pushUrl('') + inputRef.current?.focus() + }, [pushUrl]) + + /* ── Run initial query if pre-filled from URL ────────────────────────────── */ + useEffect(() => { + if (initialQuery?.trim().length >= 2) { + fetchResults(initialQuery.trim(), 1) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleDeleted = useCallback((id) => { + setResults((prev) => prev.filter((p) => p.id !== id)) + }, []) + + const hasMore = meta ? meta.current_page < meta.last_page : false + const noResults = searched && !loading && results.length === 0 + const hasResults = results.length > 0 + + return ( +
+
+
+ + {/* ── Main ─────────────────────────────────────────────────────── */} +
+ + {/* Header */} +
+

+ + Search Posts +

+

+ Search by keywords, hashtags, or phrases +

+
+ + {/* Search box */} +
+
+ + + {query && ( + + )} +
+ + + {/* Skeletons while first load */} + {loading && !hasResults && ( + <>{Array.from({ length: 3 }).map((_, i) => )} + )} + + {/* Idle / too short */} + {!searched && !loading && ( +
+
+ +
+

+ Type at least 2 characters to search posts +

+
+ )} + + {/* No results */} + {noResults && ( +
+
+ +
+

No results

+

+ Nothing matched “{query}”. + Try different keywords or a hashtag. +

+
+ )} + + {/* Results meta */} + {hasResults && meta && ( +

+ {meta.total.toLocaleString()} result{meta.total !== 1 ? 's' : ''} for{' '} + “{query}” +

+ )} + + {/* Post cards */} + {results.map((post) => ( + + ))} + + {/* Loading more indicator */} + {loading && hasResults && ( +
+ +
+ )} + + {/* Load more */} + {!loading && hasMore && ( +
+ +
+ )} +
+ + {/* ── Sidebar ──────────────────────────────────────────────────── */} + +
+
+
+ ) +} diff --git a/resources/js/Pages/Feed/TrendingFeed.jsx b/resources/js/Pages/Feed/TrendingFeed.jsx new file mode 100644 index 00000000..96da2a21 --- /dev/null +++ b/resources/js/Pages/Feed/TrendingFeed.jsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { usePage } from '@inertiajs/react' +import axios from 'axios' +import PostCard from '../../Components/Feed/PostCard' +import PostCardSkeleton from '../../Components/Feed/PostCardSkeleton' + +function TrendingHashtagsSidebar({ hashtags, activeTag = null }) { + if (!hashtags || hashtags.length === 0) return null + return ( +
+
+ + Trending Tags +
+
+ {hashtags.map((h) => ( + + #{h.tag} + + {h.post_count} posts + + + ))} +
+
+ ) +} + +export default function TrendingFeed() { + const { props } = usePage() + const { auth, trendingHashtags } = props + const authUser = auth?.user ?? null + + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [loaded, setLoaded] = useState(false) + + const fetchFeed = useCallback(async (p = 1) => { + setLoading(true) + try { + const { data } = await axios.get('/api/feed/trending', { params: { page: p } }) + setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data]) + setHasMore(data.meta.current_page < data.meta.last_page) + setPage(p) + } catch { + // + } finally { + setLoading(false) + setLoaded(true) + } + }, []) + + useEffect(() => { fetchFeed(1) }, []) + + const handleDeleted = useCallback((id) => setPosts((prev) => prev.filter((p) => p.id !== id)), []) + + return ( +
+
+
+ {/* ── Main feed ──────────────────────────────────────────────── */} +
+
+

+ + Trending +

+

Most engaging posts right now

+
+ + {!loaded && loading && ( + <>{Array.from({ length: 3 }).map((_, i) => )} + )} + + {loaded && !loading && posts.length === 0 && ( +
+
+ +
+

Nothing trending yet

+

Check back soon — posts are ranked by engagement.

+
+ )} + + {posts.map((post) => ( + + ))} + + {loaded && hasMore && ( +
+ +
+ )} +
+ + {/* ── Sidebar ────────────────────────────────────────────────── */} + +
+
+
+ ) +} diff --git a/resources/js/Pages/Forum/ForumCategory.jsx b/resources/js/Pages/Forum/ForumCategory.jsx new file mode 100644 index 00000000..44196200 --- /dev/null +++ b/resources/js/Pages/Forum/ForumCategory.jsx @@ -0,0 +1,81 @@ +import React from 'react' +import Breadcrumbs from '../../components/forum/Breadcrumbs' +import ThreadRow from '../../components/forum/ThreadRow' +import Pagination from '../../components/forum/Pagination' +import Button from '../../components/ui/Button' + +export default function ForumCategory({ category, threads = [], pagination = {}, isAuthenticated = false }) { + const name = category?.name ?? 'Category' + const slug = category?.slug + + const breadcrumbs = [ + { label: 'Home', href: '/' }, + { label: 'Forum', href: '/forum' }, + { label: name }, + ] + + return ( +
+ {/* Breadcrumbs */} + + + {/* Header */} +
+
+

Forum

+

{name}

+
+ {isAuthenticated && slug && ( + + + + )} +
+ + {/* Thread list */} +
+ {/* Column header */} +
+ Threads + Posts +
+ + {threads.length === 0 ? ( +
+ + + +

No threads in this section yet.

+ {isAuthenticated && slug && ( + + Be the first to start a discussion → + + )} +
+ ) : ( +
+ {threads.map((thread, i) => ( + + ))} +
+ )} +
+ + {/* Pagination */} + {pagination?.last_page > 1 && ( +
+ +
+ )} +
+ ) +} diff --git a/resources/js/Pages/Forum/ForumEditPost.jsx b/resources/js/Pages/Forum/ForumEditPost.jsx new file mode 100644 index 00000000..1edca49d --- /dev/null +++ b/resources/js/Pages/Forum/ForumEditPost.jsx @@ -0,0 +1,74 @@ +import React, { useState, useCallback } from 'react' +import Breadcrumbs from '../../components/forum/Breadcrumbs' +import Button from '../../components/ui/Button' +import RichTextEditor from '../../components/forum/RichTextEditor' + +export default function ForumEditPost({ post, thread, csrfToken, errors = {} }) { + const [content, setContent] = useState(post?.content ?? '') + const [submitting, setSubmitting] = useState(false) + + const breadcrumbs = [ + { label: 'Home', href: '/' }, + { label: 'Forum', href: '/forum' }, + { label: thread?.title ?? 'Thread', href: thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum' }, + { label: 'Edit post' }, + ] + + const handleSubmit = useCallback((e) => { + if (submitting) return + setSubmitting(true) + // Let the form submit normally for PRG + }, [submitting]) + + return ( +
+ + + {/* Header */} +
+

Edit

+

Edit post

+
+ + {/* Form */} +
+ + + + {/* Rich text editor */} +
+ + + +
+ + {/* Actions */} +
+ + ← Cancel + + +
+
+
+ ) +} diff --git a/resources/js/Pages/Forum/ForumIndex.jsx b/resources/js/Pages/Forum/ForumIndex.jsx new file mode 100644 index 00000000..67128391 --- /dev/null +++ b/resources/js/Pages/Forum/ForumIndex.jsx @@ -0,0 +1,31 @@ +import React from 'react' +import CategoryCard from '../../components/forum/CategoryCard' + +export default function ForumIndex({ categories = [] }) { + return ( +
+ {/* Header */} +
+

Community

+

Forum

+

Browse forum sections and join the conversation.

+
+ + {/* Category grid */} + {categories.length === 0 ? ( +
+ + + +

No forum categories available yet.

+
+ ) : ( +
+ {categories.map((cat) => ( + + ))} +
+ )} +
+ ) +} diff --git a/resources/js/Pages/Forum/ForumNewThread.jsx b/resources/js/Pages/Forum/ForumNewThread.jsx new file mode 100644 index 00000000..c18fd469 --- /dev/null +++ b/resources/js/Pages/Forum/ForumNewThread.jsx @@ -0,0 +1,91 @@ +import React, { useState, useCallback } from 'react' +import Breadcrumbs from '../../components/forum/Breadcrumbs' +import Button from '../../components/ui/Button' +import TextInput from '../../components/ui/TextInput' +import RichTextEditor from '../../components/forum/RichTextEditor' + +export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {} }) { + const [title, setTitle] = useState(oldValues.title ?? '') + const [content, setContent] = useState(oldValues.content ?? '') + const [submitting, setSubmitting] = useState(false) + + const slug = category?.slug + const categoryName = category?.name ?? 'Category' + + const breadcrumbs = [ + { label: 'Home', href: '/' }, + { label: 'Forum', href: '/forum' }, + { label: categoryName, href: slug ? `/forum/${slug}` : '/forum' }, + { label: 'New thread' }, + ] + + const handleSubmit = useCallback(async (e) => { + e.preventDefault() + if (submitting) return + setSubmitting(true) + + // Standard form submission to keep server-side validation + redirect + e.target.submit() + }, [submitting]) + + return ( +
+ + + {/* Header */} +
+

New thread

+

+ Create thread in {categoryName} +

+
+ + {/* Form */} +
+ + + setTitle(e.target.value)} + required + maxLength={255} + placeholder="Thread title…" + error={errors.title} + /> + + {/* Rich text editor */} +
+ + + +
+ + {/* Submit */} +
+ + ← Cancel + + +
+ +
+ ) +} diff --git a/resources/js/Pages/Forum/ForumThread.jsx b/resources/js/Pages/Forum/ForumThread.jsx new file mode 100644 index 00000000..e440580b --- /dev/null +++ b/resources/js/Pages/Forum/ForumThread.jsx @@ -0,0 +1,200 @@ +import React, { useState, useCallback } from 'react' +import Breadcrumbs from '../../components/forum/Breadcrumbs' +import PostCard from '../../components/forum/PostCard' +import ReplyForm from '../../components/forum/ReplyForm' +import Pagination from '../../components/forum/Pagination' + +export default function ForumThread({ + thread, + category, + author, + opPost, + posts = [], + pagination = {}, + replyCount = 0, + sort = 'asc', + quotedPost = null, + replyPrefill = '', + isAuthenticated = false, + canModerate = false, + csrfToken = '', + status = null, +}) { + const [currentSort, setCurrentSort] = useState(sort) + + const breadcrumbs = [ + { label: 'Home', href: '/' }, + { label: 'Forum', href: '/forum' }, + { label: category?.name ?? 'Category', href: category?.slug ? `/forum/${category.slug}` : '/forum' }, + { label: thread?.title ?? 'Thread' }, + ] + + const handleSortToggle = useCallback(() => { + const newSort = currentSort === 'asc' ? 'desc' : 'asc' + setCurrentSort(newSort) + const url = new URL(window.location.href) + url.searchParams.set('sort', newSort) + window.location.href = url.toString() + }, [currentSort]) + + return ( +
+ + + {/* Status flash */} + {status && ( +
+ {status} +
+ )} + + {/* Thread header card */} +
+
+
+

{thread?.title}

+
+ By {author?.name ?? 'Unknown'} + + {thread?.created_at && ( + + )} +
+
+ +
+ + {number(thread?.views ?? 0)} views + + + {number(replyCount)} replies + + {thread?.is_pinned && ( + Pinned + )} + {thread?.is_locked && ( + Locked + )} +
+
+ + {/* Moderation tools */} + {canModerate && ( +
+ {thread?.is_locked ? ( + + ) : ( + + )} + {thread?.is_pinned ? ( + + ) : ( + + )} +
+ )} +
+ + {/* Sort toggle + reply count */} +
+

{number(replyCount)} {replyCount === 1 ? 'reply' : 'replies'}

+ +
+ + {/* OP Post */} + {opPost && ( + + )} + + {/* Reply list */} +
+ {posts.length === 0 ? ( +
+ No replies yet. Be the first to respond! +
+ ) : ( + posts.map((post) => ( + + )) + )} +
+ + {/* Pagination */} + {pagination?.last_page > 1 && ( +
+ +
+ )} + + {/* Reply form or locked / auth prompt */} + {isAuthenticated ? ( + thread?.is_locked ? ( +
+ This thread is locked. Replies are disabled. +
+ ) : ( + + ) + ) : ( +
+ Sign in to post a reply. +
+ )} +
+ ) +} + +function ModForm({ action, csrf, label, variant }) { + const colors = variant === 'danger' + ? 'bg-red-500/15 text-red-300 hover:bg-red-500/25 border-red-500/20' + : 'bg-amber-500/15 text-amber-300 hover:bg-amber-500/25 border-amber-500/20' + + return ( +
+ + +
+ ) +} + +function number(n) { + return (n ?? 0).toLocaleString() +} + +function formatDate(dateStr) { + try { + const d = new Date(dateStr) + return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }) + + ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + } catch { + return '' + } +} diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx new file mode 100644 index 00000000..09f70044 --- /dev/null +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -0,0 +1,177 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { usePage } from '@inertiajs/react' +import ProfileHero from '../../Components/Profile/ProfileHero' +import ProfileStatsRow from '../../Components/Profile/ProfileStatsRow' +import ProfileTabs from '../../Components/Profile/ProfileTabs' +import TabArtworks from '../../Components/Profile/tabs/TabArtworks' +import TabAbout from '../../Components/Profile/tabs/TabAbout' +import TabStats from '../../Components/Profile/tabs/TabStats' +import TabFavourites from '../../Components/Profile/tabs/TabFavourites' +import TabCollections from '../../Components/Profile/tabs/TabCollections' +import TabActivity from '../../Components/Profile/tabs/TabActivity' +import TabPosts from '../../Components/Profile/tabs/TabPosts' + +const VALID_TABS = ['artworks', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity'] + +function getInitialTab() { + try { + const sp = new URLSearchParams(window.location.search) + const t = sp.get('tab') + return VALID_TABS.includes(t) ? t : 'artworks' + } catch { + return 'artworks' + } +} + +/** + * ProfileShow – Inertia page for /@username + * + * Props injected by ProfileController::renderUserProfile() + */ +export default function ProfileShow() { + const { props } = usePage() + + const { + user, + profile, + artworks, + featuredArtworks, + favourites, + stats, + socialLinks, + followerCount, + recentFollowers, + viewerIsFollowing, + heroBgUrl, + profileComments, + countryName, + isOwner, + auth, + } = props + + const [activeTab, setActiveTab] = useState(getInitialTab) + + const handleTabChange = useCallback((tab) => { + if (!VALID_TABS.includes(tab)) return + setActiveTab(tab) + + // Update URL query param without full navigation + try { + const url = new URL(window.location.href) + if (tab === 'artworks') { + url.searchParams.delete('tab') + } else { + url.searchParams.set('tab', tab) + } + window.history.pushState({}, '', url.toString()) + } catch (_) {} + }, []) + + // Handle browser back/forward + useEffect(() => { + const onPop = () => setActiveTab(getInitialTab()) + window.addEventListener('popstate', onPop) + return () => window.removeEventListener('popstate', onPop) + }, []) + + const isLoggedIn = !!(auth?.user) + + // Normalise artwork list (SSR may send cursor-paginated object) + const artworkList = Array.isArray(artworks) + ? artworks + : (artworks?.data ?? []) + const artworkNextCursor = artworks?.next_cursor ?? null + + // Normalise social links (may be object keyed by platform, or array) + const socialLinksObj = Array.isArray(socialLinks) + ? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {}) + : (socialLinks ?? {}) + + return ( +
+ {/* Hero section */} + + + {/* Stats pills row */} + + + {/* Sticky tabs */} + + + {/* Tab content area */} +
+ {activeTab === 'artworks' && ( + + )} + {activeTab === 'posts' && ( + + )} + {activeTab === 'collections' && ( + + )} + {activeTab === 'about' && ( + + )} + {activeTab === 'stats' && ( + + )} + {activeTab === 'favourites' && ( + + )} + {activeTab === 'activity' && ( + + )} +
+
+ ) +} diff --git a/resources/js/Pages/Upload/Index.jsx b/resources/js/Pages/Upload/Index.jsx index 44819238..41812c4c 100644 --- a/resources/js/Pages/Upload/Index.jsx +++ b/resources/js/Pages/Upload/Index.jsx @@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/react' import TagInput from '../../components/tags/TagInput' import UploadWizard from '../../components/upload/UploadWizard' import Checkbox from '../../Components/ui/Checkbox' +import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices' const phases = { idle: 'idle', @@ -179,13 +180,21 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { }, []) const pushNotice = useCallback((type, message) => { + const normalizedType = ['success', 'warning', 'error'].includes(String(type || '').toLowerCase()) + ? String(type).toLowerCase() + : 'error' const id = `${Date.now()}-${Math.random().toString(16).slice(2)}` - dispatch({ type: 'PUSH_NOTICE', notice: { id, type, message } }) + dispatch({ type: 'PUSH_NOTICE', notice: { id, type: normalizedType, message } }) window.setTimeout(() => { dispatch({ type: 'REMOVE_NOTICE', id }) }, 4500) }, []) + const pushMappedNotice = useCallback((notice) => { + if (!notice?.message) return + pushNotice(notice.type || 'error', notice.message) + }, [pushNotice]) + const previewUrl = useMemo(() => { if (state.previewUrl) return state.previewUrl if (!state.filePreviewUrl) return null @@ -276,12 +285,12 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { } return { sessionId: data.session_id, uploadToken: data.upload_token } } catch (error) { - const message = extractErrorMessage(error, 'Failed to initialize upload session.') - dispatch({ type: 'INIT_ERROR', error: message }) - pushNotice('error', message) + const notice = mapUploadErrorNotice(error, 'Failed to initialize upload session.') + dispatch({ type: 'INIT_ERROR', error: notice.message }) + pushMappedNotice(notice) return null } - }, [state.file, userId, extractErrorMessage, pushNotice]) + }, [state.file, userId, pushMappedNotice]) const createDraft = useCallback(async () => { if (state.artworkId) return state.artworkId @@ -302,12 +311,12 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { } throw new Error('missing_artwork_id') } catch (error) { - const message = extractErrorMessage(error, 'Unable to create draft metadata.') - dispatch({ type: 'FINISH_ERROR', error: message }) - pushNotice('error', message) + const notice = mapUploadErrorNotice(error, 'Unable to create draft metadata.') + dispatch({ type: 'FINISH_ERROR', error: notice.message }) + pushMappedNotice(notice) return null } - }, [state.artworkId, state.metadata, state.draftId, extractErrorMessage, pushNotice]) + }, [state.artworkId, state.metadata, state.draftId, pushMappedNotice]) const syncArtworkTags = useCallback(async (artworkId) => { const tags = Array.from(new Set(parseUiTags(state.metadata.tags))) @@ -319,11 +328,11 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { await window.axios.put(`/api/artworks/${artworkId}/tags`, { tags }) return true } catch (error) { - const message = extractErrorMessage(error, 'Tag sync failed. Upload will continue.') - pushNotice('error', message) + const notice = mapUploadErrorNotice(error, 'Tag sync failed. Upload will continue.') + pushMappedNotice({ ...notice, type: 'warning' }) return false } - }, [state.metadata.tags, extractErrorMessage, pushNotice]) + }, [state.metadata.tags, pushMappedNotice]) const fetchStatus = useCallback(async (sessionId, uploadToken) => { const res = await window.axios.get(`/api/uploads/status/${sessionId}`, { @@ -393,9 +402,9 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { try { status = await fetchStatus(sessionId, uploadToken) } catch (error) { - const message = extractErrorMessage(error, 'Unable to resume upload.') - dispatch({ type: 'UPLOAD_ERROR', error: message }) - pushNotice('error', message) + const notice = mapUploadErrorNotice(error, 'Unable to resume upload.') + dispatch({ type: 'UPLOAD_ERROR', error: notice.message }) + pushMappedNotice(notice) return false } @@ -414,39 +423,53 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { offset = nextOffset } } catch (error) { - const message = extractErrorMessage(error, 'File upload failed. Please retry.') - dispatch({ type: 'UPLOAD_ERROR', error: message }) - pushNotice('error', message) + const notice = mapUploadErrorNotice(error, 'File upload failed. Please retry.') + dispatch({ type: 'UPLOAD_ERROR', error: notice.message }) + pushMappedNotice(notice) return false } } return true - }, [chunkSize, fetchStatus, uploadChunk]) + }, [chunkSize, fetchStatus, uploadChunk, pushMappedNotice]) const finishUpload = useCallback(async (sessionId, uploadToken, artworkId) => { dispatch({ type: 'FINISH_START' }) try { const res = await window.axios.post( '/api/uploads/finish', - { session_id: sessionId, artwork_id: artworkId, upload_token: uploadToken }, + { + session_id: sessionId, + artwork_id: artworkId, + upload_token: uploadToken, + file_name: String(state.file?.name || ''), + }, { headers: { 'X-Upload-Token': uploadToken } } ) const data = res.data || {} const previewPath = data.preview_path const previewUrl = previewPath ? `${filesCdnUrl}/${previewPath}` : null dispatch({ type: 'FINISH_SUCCESS', status: data.status, previewUrl }) + + const finishNotice = mapUploadResultNotice(data, { + fallbackType: String(data.status || '').toLowerCase() === 'queued' ? 'warning' : 'success', + fallbackMessage: String(data.status || '').toLowerCase() === 'queued' + ? 'Upload received. Processing is queued.' + : 'Upload finalized successfully.', + }) + pushMappedNotice(finishNotice) + if (userId) { clearStoredSession(userId) } return true } catch (error) { - const message = extractErrorMessage(error, 'Upload finalization failed.') - dispatch({ type: 'FINISH_ERROR', error: message }) - pushNotice('error', message) + const notice = mapUploadErrorNotice(error, 'Upload finalization failed.') + dispatch({ type: 'FINISH_ERROR', error: notice.message }) + pushMappedNotice(notice) return false } - }, [filesCdnUrl, userId]) + }, [filesCdnUrl, userId, pushMappedNotice]) const startUpload = useCallback(async () => { if (!state.file) { @@ -529,6 +552,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { } dispatch({ type: 'CANCEL_SUCCESS' }) dispatch({ type: 'RESET' }) + pushNotice('warning', 'Upload cancelled.') return } @@ -547,7 +571,8 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { } dispatch({ type: 'CANCEL_SUCCESS' }) dispatch({ type: 'RESET' }) - }, [state.sessionId, state.uploadToken, userId]) + pushNotice('warning', 'Upload cancelled.') + }, [state.sessionId, state.uploadToken, userId, pushNotice]) return { state, @@ -683,7 +708,9 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) { aria-live="polite" className={`rounded-xl border px-4 py-3 text-sm ${notice.type === 'error' ? 'border-red-500/40 bg-red-500/10 text-red-100' - : 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`} + : notice.type === 'warning' + ? 'border-amber-400/40 bg-amber-400/10 text-amber-100' + : 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`} > {notice.message} diff --git a/resources/js/components/Feed/EmbeddedArtworkCard.jsx b/resources/js/components/Feed/EmbeddedArtworkCard.jsx new file mode 100644 index 00000000..e2d5f720 --- /dev/null +++ b/resources/js/components/Feed/EmbeddedArtworkCard.jsx @@ -0,0 +1,54 @@ +import React from 'react' + +/** + * Compact artwork card for embedding inside a PostCard. + * Shows thumbnail, title and original author with attribution. + */ +export default function EmbeddedArtworkCard({ artwork }) { + if (!artwork) return null + + const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}` + const authorUrl = `/@${artwork.author.username}` + + return ( + + {/* Thumbnail */} +
+ {artwork.thumb_url ? ( + {artwork.title} + ) : ( +
+ +
+ )} +
+ + {/* Meta */} +
+ + ) +} + +function slugify(str) { + return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') +} diff --git a/resources/js/components/Feed/FeedSidebar.jsx b/resources/js/components/Feed/FeedSidebar.jsx new file mode 100644 index 00000000..4a884cdd --- /dev/null +++ b/resources/js/components/Feed/FeedSidebar.jsx @@ -0,0 +1,396 @@ +import React, { useState, useEffect } from 'react' +import axios from 'axios' + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function fmt(n) { + if (n === null || n === undefined) return '0' + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k' + return String(n) +} + +const SOCIAL_META = { + twitter: { icon: 'fa-brands fa-x-twitter', label: 'Twitter / X', prefix: 'https://x.com/' }, + instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram', prefix: 'https://instagram.com/' }, + deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt', prefix: 'https://deviantart.com/' }, + artstation: { icon: 'fa-brands fa-artstation', label: 'ArtStation', prefix: 'https://artstation.com/' }, + behance: { icon: 'fa-brands fa-behance', label: 'Behance', prefix: 'https://behance.net/' }, + website: { icon: 'fa-solid fa-globe', label: 'Website', prefix: '' }, + youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube', prefix: '' }, + twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch', prefix: '' }, +} + +function SideCard({ title, icon, children, className = '' }) { + return ( +
+ {title && ( +
+ {icon && } + {title} +
+ )} + {children} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stats card +// ───────────────────────────────────────────────────────────────────────────── + +function StatsCard({ stats, followerCount, user, onTabChange }) { + const items = [ + { + label: 'Artworks', + value: fmt(stats?.uploads_count ?? 0), + icon: 'fa-solid fa-image', + color: 'text-sky-400', + tab: 'artworks', + }, + { + label: 'Followers', + value: fmt(followerCount ?? stats?.followers_count ?? 0), + icon: 'fa-solid fa-user-group', + color: 'text-violet-400', + tab: null, + }, + { + label: 'Following', + value: fmt(stats?.following_count ?? 0), + icon: 'fa-solid fa-user-plus', + color: 'text-emerald-400', + tab: null, + }, + { + label: 'Awards', + value: fmt(stats?.awards_received_count ?? 0), + icon: 'fa-solid fa-trophy', + color: 'text-amber-400', + tab: 'stats', + }, + ] + + return ( + +
+ {items.map((item) => ( + + ))} +
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// About card +// ───────────────────────────────────────────────────────────────────────────── + +function AboutCard({ user, profile, socialLinks, countryName }) { + const bio = profile?.bio || profile?.about || profile?.description + const website = profile?.website || user?.website + + const hasSocials = socialLinks && Object.keys(socialLinks).length > 0 + const hasContent = bio || countryName || website || hasSocials + + if (!hasContent) return null + + return ( + +
+ {bio && ( +

{bio}

+ )} + +
+ {countryName && ( +
+ + {countryName} +
+ )} + {website && ( + + )} +
+ + {hasSocials && ( +
+ {Object.entries(socialLinks).map(([platform, link]) => { + const meta = SOCIAL_META[platform] ?? SOCIAL_META.website + const url = link.url || (meta.prefix ? meta.prefix + link.handle : null) + if (!url) return null + return ( + + + + ) + })} +
+ )} +
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Recent followers card +// ───────────────────────────────────────────────────────────────────────────── + +function RecentFollowersCard({ recentFollowers, followerCount, onTabChange }) { + const followers = recentFollowers ?? [] + if (followers.length === 0) return null + + return ( + +
+ {followers.slice(0, 6).map((f) => ( + + {f.username} +
+

+ {f.name || f.uname || f.username} +

+

@{f.username}

+
+
+ ))} + + {followerCount > 6 && ( + + )} +
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Trending hashtags card +// ───────────────────────────────────────────────────────────────────────────── + +function TrendingHashtagsCard() { + const [tags, setTags] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + axios.get('/api/feed/hashtags/trending', { params: { limit: 8 } }) + .then(({ data }) => setTags(Array.isArray(data.hashtags) ? data.hashtags : [])) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + if (!loading && tags.length === 0) return null + + return ( + +
+ {loading + ? [1, 2, 3, 4].map((i) => ( +
+ + + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Suggested to follow card +// ───────────────────────────────────────────────────────────────────────────── + +function SuggestionsCard({ excludeUsername, isLoggedIn }) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!isLoggedIn) { setLoading(false); return } + axios.get('/api/search/users', { params: { q: '', per_page: 5 } }) + .then(({ data }) => { + const list = (data.data ?? []).filter((u) => u.username !== excludeUsername).slice(0, 4) + setUsers(list) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [excludeUsername, isLoggedIn]) + + if (!isLoggedIn) return null + if (!loading && users.length === 0) return null + + return ( + +
+ {loading ? ( + [1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+ )) + ) : ( + users.map((u) => ( + + {u.username} +
+

+ {u.name || u.username} +

+

@{u.username}

+
+ + View + +
+ )) + )} +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main export +// ───────────────────────────────────────────────────────────────────────────── + +/** + * FeedSidebar + * + * Props: + * user object { id, username, name, uploads_count, ...} + * profile object { bio, about, country, website, ... } + * stats object from user_statistics + * followerCount number + * recentFollowers array [{ id, username, name, avatar_url, profile_url }] + * socialLinks object keyed by platform + * countryName string|null + * isLoggedIn boolean + * onTabChange function(tab) + */ +export default function FeedSidebar({ + user, + profile, + stats, + followerCount, + recentFollowers, + socialLinks, + countryName, + isLoggedIn, + onTabChange, +}) { + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/resources/js/components/Feed/LinkPreviewCard.jsx b/resources/js/components/Feed/LinkPreviewCard.jsx new file mode 100644 index 00000000..5cd77611 --- /dev/null +++ b/resources/js/components/Feed/LinkPreviewCard.jsx @@ -0,0 +1,96 @@ +import React from 'react' + +/** + * LinkPreviewCard + * Renders an OG/OpenGraph link preview card. + * + * Props: + * preview { url, title, description, image, site_name } + * onDismiss function|null — if provided, shows a dismiss ✕ button + * loading boolean — shows skeleton while fetching + */ +export default function LinkPreviewCard({ preview, onDismiss, loading = false }) { + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ) + } + + if (!preview?.url) return null + + const domain = (() => { + try { return new URL(preview.url).hostname.replace(/^www\./, '') } + catch { return preview.site_name ?? '' } + })() + + return ( +
+ e.stopPropagation()} + > + {/* Image */} + {preview.image ? ( +
+ { e.currentTarget.parentElement.style.display = 'none' }} + /> +
+ ) : ( +
+ +
+ )} + + {/* Text */} +
+ {preview.site_name && ( +

+ {preview.site_name} +

+ )} + {preview.title && ( +

+ {preview.title} +

+ )} + {preview.description && ( +

+ {preview.description} +

+ )} +

+ {domain} +

+
+
+ + {/* Dismiss button */} + {onDismiss && ( + + )} +
+ ) +} diff --git a/resources/js/components/Feed/PostActions.jsx b/resources/js/components/Feed/PostActions.jsx new file mode 100644 index 00000000..7db05a26 --- /dev/null +++ b/resources/js/components/Feed/PostActions.jsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react' +import axios from 'axios' + +/** + * PostActions: Like toggle, Comment toggle, Share menu, Report + */ +export default function PostActions({ + post, + isLoggedIn, + onCommentToggle, + onReactionChange, +}) { + const [liked, setLiked] = useState(post.viewer_liked ?? false) + const [likeCount, setLikeCount] = useState(post.reactions_count ?? 0) + const [busy, setBusy] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [shareMsg, setShareMsg] = useState(null) + + const handleLike = async () => { + if (!isLoggedIn) { + window.location.href = '/login' + return + } + if (busy) return + setBusy(true) + try { + if (liked) { + await axios.delete(`/api/posts/${post.id}/reactions/like`) + setLiked(false) + setLikeCount((c) => Math.max(0, c - 1)) + onReactionChange?.({ liked: false, count: Math.max(0, likeCount - 1) }) + } else { + await axios.post(`/api/posts/${post.id}/reactions`, { reaction: 'like' }) + setLiked(true) + setLikeCount((c) => c + 1) + onReactionChange?.({ liked: true, count: likeCount + 1 }) + } + } catch { + // ignore + } finally { + setBusy(false) + } + } + + const handleCopyLink = () => { + const url = `${window.location.origin}/@${post.author.username}?tab=posts&post=${post.id}` + navigator.clipboard?.writeText(url) + setShareMsg('Link copied!') + setTimeout(() => setShareMsg(null), 2000) + setMenuOpen(false) + } + + const handleReport = async () => { + setMenuOpen(false) + const reason = window.prompt('Why are you reporting this post? (required)') + if (!reason?.trim()) return + try { + await axios.post(`/api/posts/${post.id}/report`, { reason: reason.trim() }) + alert('Report submitted. Thank you!') + } catch (err) { + if (err.response?.data?.message) { + alert(err.response.data.message) + } + } + } + + return ( +
+ {/* Like */} + + + {/* Comment toggle */} + + + {/* Share / More menu */} +
+ + + {menuOpen && ( +
setMenuOpen(false)} + > + + {isLoggedIn && ( + + )} +
+ )} +
+ + {/* Share feedback toast */} + {shareMsg && ( + + {shareMsg} + + )} +
+ ) +} diff --git a/resources/js/components/Feed/PostCard.jsx b/resources/js/components/Feed/PostCard.jsx new file mode 100644 index 00000000..547555a9 --- /dev/null +++ b/resources/js/components/Feed/PostCard.jsx @@ -0,0 +1,404 @@ +import React, { useState } from 'react' +import PostActions from './PostActions' +import PostComments from './PostComments' +import EmbeddedArtworkCard from './EmbeddedArtworkCard' +import VisibilityPill from './VisibilityPill' +import LinkPreviewCard from './LinkPreviewCard' + +function formatRelative(isoString) { + const diff = Date.now() - new Date(isoString).getTime() + const s = Math.floor(diff / 1000) + if (s < 60) return 'just now' + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + return `${d}d ago` +} + +function formatScheduledDate(isoString) { + const d = new Date(isoString) + return d.toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }) +} + +/** Render plain text body with #hashtag links */ +function BodyWithHashtags({ html }) { + // The body may already be sanitised HTML from the server. We replace + // #tag patterns in text nodes (not inside existing anchor elements) with + // anchor links pointing to /tags/{tag}. + const processed = html.replace( + /(? `#${tag}`, + ) + return ( +
+ ) +} + +/** + * PostCard + * Renders a single post in the feed. Supports text + artwork_share types. + * + * Props: + * post object (formatted by PostFeedService::formatPost) + * isLoggedIn boolean + * viewerUsername string|null + * onDelete function(postId) + * onUnsaved function(postId) — called when viewer unsaves this post + */ +export default function PostCard({ post, isLoggedIn = false, viewerUsername = null, onDelete, onUnsaved }) { + const [showComments, setShowComments] = useState(false) + const [postData, setPostData] = useState(post) + const [editMode, setEditMode] = useState(false) + const [editBody, setEditBody] = useState(post.body ?? '') + const [saving, setSaving] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [saveLoading, setSaveLoading] = useState(false) + const [analyticsOpen, setAnalyticsOpen] = useState(false) + const [analytics, setAnalytics] = useState(null) + + const isOwn = viewerUsername && post.author.username === viewerUsername + + const handleSaveEdit = async () => { + setSaving(true) + try { + const { default: axios } = await import('axios') + const { data } = await axios.patch(`/api/posts/${post.id}`, { body: editBody }) + setPostData(data.post) + setEditMode(false) + } catch { + // + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (!window.confirm('Delete this post?')) return + try { + const { default: axios } = await import('axios') + await axios.delete(`/api/posts/${post.id}`) + onDelete?.(post.id) + } catch { + // + } + } + + const handlePin = async () => { + const { default: axios } = await import('axios') + try { + if (postData.is_pinned) { + await axios.delete(`/api/posts/${post.id}/pin`) + setPostData((p) => ({ ...p, is_pinned: false, pinned_order: null })) + } else { + const { data } = await axios.post(`/api/posts/${post.id}/pin`) + setPostData((p) => ({ ...p, is_pinned: true, pinned_order: data.pinned_order ?? 1 })) + } + } catch { + // + } + setMenuOpen(false) + } + + const handleSaveToggle = async () => { + if (!isLoggedIn || saveLoading) return + setSaveLoading(true) + const { default: axios } = await import('axios') + try { + if (postData.viewer_saved) { + await axios.delete(`/api/posts/${post.id}/save`) + setPostData((p) => ({ ...p, viewer_saved: false, saves_count: Math.max(0, (p.saves_count ?? 1) - 1) })) + onUnsaved?.(post.id) + } else { + await axios.post(`/api/posts/${post.id}/save`) + setPostData((p) => ({ ...p, viewer_saved: true, saves_count: (p.saves_count ?? 0) + 1 })) + } + } catch { + // + } finally { + setSaveLoading(false) + } + } + + const handleOpenAnalytics = async () => { + if (!isOwn) return + setAnalyticsOpen(true) + if (!analytics) { + const { default: axios } = await import('axios') + try { + const { data } = await axios.get(`/api/posts/${post.id}/analytics`) + setAnalytics(data) + } catch { + setAnalytics(null) + } + } + } + + return ( +
+ {/* ── Pinned banner ──────────────────────────────────────────────── */} + {postData.is_pinned && ( +
+ + Pinned post +
+ )} + + {/* ── Scheduled banner (owner only) ─────────────────────────────── */} + {isOwn && postData.status === 'scheduled' && postData.publish_at && ( +
+ + Scheduled for {formatScheduledDate(postData.publish_at)} +
+ )} + + {/* ── Achievement badge ──────────────────────────────────────────── */} + {postData.type === 'achievement' && ( +
+ + Achievement unlocked +
+ )} + + {/* ── Header ─────────────────────────────────────────────────────── */} +
+ + {post.author.name} + + +
+
+ + {post.author.name || `@${post.author.username}`} + + @{post.author.username} + {post.meta?.tagged_users?.length > 0 && ( + + with + {post.meta.tagged_users.map((u, i) => ( + + {i > 0 && ,} + + @{u.username} + + + ))} + + )} +
+
+ {formatRelative(post.created_at)} + · + +
+
+ + {/* Right-side actions: save + owner menu */} +
+ {/* Save / bookmark button */} + {isLoggedIn && !isOwn && ( + + )} + + {/* Analytics for owner */} + {isOwn && ( + + )} + + {/* Owner menu */} + {isOwn && ( +
+ + {menuOpen && ( +
+ + + +
+ )} +
+ )} +
+
+ + {/* ── Body ─────────────────────────────────────────────────────────── */} +
+ {editMode ? ( +
+ - @error('content') -
{{ $message }}
- @enderror -
- -
- -
- -
+
+ @endsection + +@push('scripts') + @vite(['resources/js/entry-forum.jsx']) +@endpush diff --git a/resources/views/forum/community/new-thread.blade.php b/resources/views/forum/community/new-thread.blade.php index a43a282d..0ab93fe8 100644 --- a/resources/views/forum/community/new-thread.blade.php +++ b/resources/views/forum/community/new-thread.blade.php @@ -1,34 +1,19 @@ @extends('layouts.nova') +@php + $forumNewThreadProps = json_encode([ + 'category' => ['id' => $category->id, 'name' => $category->name, 'slug' => $category->slug], + 'csrfToken' => csrf_token(), + 'errors' => $errors->toArray(), + 'oldValues' => ['title' => old('title', ''), 'content' => old('content', '')], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); +@endphp + @section('content') -
-
- ← Back to section -

Create thread in {{ $category->name }}

-
- -
- @csrf - -
- - - @error('title') -
{{ $message }}
- @enderror -
- -
- - - @error('content') -
{{ $message }}
- @enderror -
- -
- -
-
-
+
+ @endsection + +@push('scripts') + @vite(['resources/js/entry-forum.jsx']) +@endpush diff --git a/resources/views/forum/community/topic.blade.php b/resources/views/forum/community/topic.blade.php index 111da82f..0f6ff31f 100644 --- a/resources/views/forum/community/topic.blade.php +++ b/resources/views/forum/community/topic.blade.php @@ -1,74 +1,37 @@ @extends('layouts.nova') @php - use Carbon\Carbon; - use Illuminate\Support\Str; + $threadsData = collect($subtopics->items())->map(fn ($sub) => [ + 'topic_id' => (int) ($sub->topic_id ?? $sub->id ?? 0), + 'topic' => $sub->topic ?? $sub->title ?? 'Untitled', + 'discuss' => $sub->discuss ?? null, + 'num_posts' => (int) ($sub->num_posts ?? 0), + 'uname' => $sub->uname ?? 'Unknown', + 'last_update' => $sub->last_update ?? $sub->post_date ?? null, + 'is_pinned' => $sub->is_pinned ?? false, + ])->values(); + + $paginationData = (isset($subtopics) && method_exists($subtopics, 'currentPage')) + ? [ + 'current_page' => $subtopics->currentPage(), + 'last_page' => $subtopics->lastPage(), + 'per_page' => $subtopics->perPage(), + 'total' => $subtopics->total(), + ] : null; + + $forumCategoryProps = json_encode([ + 'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''], + 'threads' => $threadsData, + 'pagination' => $paginationData, + 'isAuthenticated' => auth()->check(), + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); @endphp @section('content') -
-
- ← Back to forum -

{{ $topic->topic ?? $topic->title ?? 'Topic' }}

- @if (!empty($topic->discuss)) -

{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}

- @endif - @if (isset($category) && auth()->check()) - - @endif -
- -
-
Threads
-
- - - - - - - - - - - @forelse (($subtopics ?? []) as $sub) - @php - $id = (int) ($sub->topic_id ?? $sub->id ?? 0); - $title = $sub->topic ?? $sub->title ?? 'Untitled'; - @endphp - - - - - - - @empty - - - - @endforelse - -
ThreadPostsByLast 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.
-
-
- - @if (isset($subtopics) && method_exists($subtopics, 'links')) -
{{ $subtopics->withQueryString()->links() }}
- @endif -
+
+ @endsection + +@push('scripts') + @vite(['resources/js/entry-forum.jsx']) +@endpush diff --git a/resources/views/forum/index.blade.php b/resources/views/forum/index.blade.php index 468ba812..b4bac9c4 100644 --- a/resources/views/forum/index.blade.php +++ b/resources/views/forum/index.blade.php @@ -1,24 +1,16 @@ @extends('layouts.nova') -@section('content') -
-
-
-

Forum

-

Browse forum sections and latest activity.

-
+@php + $forumIndexProps = json_encode([ + 'categories' => $categories ?? [], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); +@endphp - @if (($categories ?? collect())->isEmpty()) -
- No forum categories available yet. -
- @else -
- @foreach ($categories as $category) - - @endforeach -
- @endif -
-
+@section('content') +
+ @endsection + +@push('scripts') + @vite(['resources/js/entry-forum.jsx']) +@endpush diff --git a/resources/views/forum/thread/show.blade.php b/resources/views/forum/thread/show.blade.php index bd32e7da..f4bd4815 100644 --- a/resources/views/forum/thread/show.blade.php +++ b/resources/views/forum/thread/show.blade.php @@ -1,124 +1,85 @@ @extends('layouts.nova') +@php + use App\Support\ForumPostContent; + use App\Support\AvatarUrl; + use Illuminate\Support\Facades\Gate; + + $filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/'); + + $serializePost = function ($post) use ($filesBaseUrl) { + $user = $post->user ?? null; + return [ + 'id' => $post->id, + 'user_id' => $post->user_id, + 'content' => $post->content, + 'rendered_content' => ForumPostContent::render($post->content), + 'created_at' => $post->created_at?->toIso8601String(), + 'edited_at' => $post->edited_at?->toIso8601String(), + 'is_edited' => (bool) $post->is_edited, + 'can_edit' => auth()->check() && ( + (int) $post->user_id === (int) auth()->id() || Gate::allows('moderate-forum') + ), + 'current_user_id' => auth()->id(), + 'user' => $user ? [ + 'id' => $user->id, + 'name' => $user->name, + 'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null), + 'role' => $user->role ?? 'member', + ] : null, + 'attachments' => collect($post->attachments ?? [])->map(fn ($a) => [ + 'id' => $a->id, + 'mime_type' => $a->mime_type, + 'url' => $filesBaseUrl !== '' ? $filesBaseUrl . '/' . ltrim($a->file_path, '/') : '/' . ltrim($a->file_path, '/'), + 'file_size' => $a->file_size, + 'width' => $a->width, + 'height' => $a->height, + ])->values()->all(), + ]; + }; + + $serializedOp = isset($opPost) && $opPost ? $serializePost($opPost) : null; + $serializedPosts = collect($posts->items())->map($serializePost)->values()->all(); + + $paginationData = [ + 'current_page' => $posts->currentPage(), + 'last_page' => $posts->lastPage(), + 'per_page' => $posts->perPage(), + 'total' => $posts->total(), + ]; +@endphp + @section('content') -
-
- - - @if (session('status')) -
- {{ session('status') }} -
- @endif - -
-
-
-

{{ $thread->title }}

-
- By {{ $author->name ?? 'Unknown' }} - - -
-
- -
- {{ number_format((int) ($thread->views ?? 0)) }} views - {{ number_format((int) ($reply_count ?? 0)) }} replies - @if ($thread->is_pinned) - Pinned - @endif - @if ($thread->is_locked) - Locked - @endif -
-
- - @can('moderate-forum') -
- @if ($thread->is_locked) -
- @csrf - -
- @else -
- @csrf - -
- @endif - - @if ($thread->is_pinned) -
- @csrf - -
- @else -
- @csrf - -
- @endif -
- @endcan -
- - @if (isset($opPost) && $opPost) - - @endif - -
- @forelse ($posts as $post) - - @empty -
- No replies yet. -
- @endforelse -
- - @if (method_exists($posts, 'links')) -
- {{ $posts->withQueryString()->links() }} -
- @endif - - @auth - @if (!$thread->is_locked) -
- @csrf -
- - Minimum 2 characters -
-
-
- - Preview (coming soon) -
- -
- @error('content') -

{{ $message }}

- @enderror - @if (!empty($quoted_post)) -

Replying with quote from {{ data_get($quoted_post, 'user.name', 'Anonymous') }}.

- @endif -
-

Markdown/BBCode + attachments will be enabled in next pass

- -
-
- @else -
- This thread is locked. Replies are disabled. -
- @endif - @else -
- Sign in to post a reply. -
- @endauth -
-
+
+@php + $forumThreadProps = json_encode([ + 'thread' => [ + 'id' => $thread->id, + 'title' => $thread->title, + 'slug' => $thread->slug, + 'views' => (int) ($thread->views ?? 0), + 'is_pinned' => (bool) $thread->is_pinned, + 'is_locked' => (bool) $thread->is_locked, + 'created_at' => $thread->created_at?->toIso8601String(), + ], + 'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''], + 'author' => ['name' => $author->name ?? 'Unknown'], + 'opPost' => $serializedOp, + 'posts' => $serializedPosts, + 'pagination' => $paginationData, + 'replyCount' => (int) ($reply_count ?? 0), + 'sort' => $sort ?? 'asc', + 'quotedPost' => $quoted_post ? ['user' => ['name' => data_get($quoted_post, 'user.name', 'Anonymous')]] : null, + 'replyPrefill' => $reply_prefill ?? '', + 'isAuthenticated' => auth()->check(), + 'canModerate' => Gate::allows('moderate-forum'), + 'csrfToken' => csrf_token(), + 'status' => session('status'), + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); +@endphp + @endsection + +@push('scripts') + @vite(['resources/js/entry-forum.jsx']) +@endpush diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php index 7c7161ca..931febac 100644 --- a/resources/views/profile/partials/update-profile-information-form.blade.php +++ b/resources/views/profile/partials/update-profile-information-form.blade.php @@ -76,9 +76,31 @@ @endif
+ {{-- Posts & Feed Settings --}} +
+

{{ __('Posts & Feed') }}

+
+
+

{{ __('Auto-post new uploads') }}

+

{{ __('Automatically create a feed post when you publish new artwork.') }}

+
+
+ + user()->profile)->auto_post_upload ? 'checked' : '' }} + class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" + /> + +
+
+
+
{{ __('Save') }} - @if (session('status') === 'profile-updated')

+ @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 + +

+ @foreach($sections as $slug => $meta) + @if($meta['auth'] && !$isAuth) + @continue + @endif + + + {{ $meta['label'] }} + + @endforeach +
diff --git a/resources/views/web/discover/for-you.blade.php b/resources/views/web/discover/for-you.blade.php index a9ad00b1..e4fbbe8b 100644 --- a/resources/views/web/discover/for-you.blade.php +++ b/resources/views/web/discover/for-you.blade.php @@ -15,28 +15,7 @@
{{-- Section switcher pills --}} -
- - - For You - - @php - $sections = [ - 'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'], - 'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'], - 'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'], - 'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'], - ]; - @endphp - @foreach($sections as $slug => $meta) - - - {{ $meta['label'] }} - - @endforeach -
+ @include('web.discover._nav', ['section' => 'for-you'])
diff --git a/resources/views/web/discover/index.blade.php b/resources/views/web/discover/index.blade.php index d093cc55..f93ab007 100644 --- a/resources/views/web/discover/index.blade.php +++ b/resources/views/web/discover/index.blade.php @@ -17,34 +17,7 @@
{{-- Section switcher pills --}} -
- @auth - - - For You - - @endauth - @php - $sections = [ - 'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'], - 'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket'], - 'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'], - 'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'], - 'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'], - 'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day'], - ]; - $active = $section ?? ''; - @endphp - @foreach($sections as $slug => $meta) - - - {{ $meta['label'] }} - - @endforeach -
+ @include('web.discover._nav', ['section' => $section ?? ''])
diff --git a/routes/api.php b/routes/api.php index 5c69c260..073ef198 100644 --- a/routes/api.php +++ b/routes/api.php @@ -446,3 +446,127 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1']) Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1']) ->post('reports', [\App\Http\Controllers\Api\ReportController::class, 'store']) ->name('api.reports.store'); + +// ── Profile API (public, throttled) ───────────────────────────────────────── +// GET /api/profile/{username}/artworks?sort=latest|trending|rising|views|favs&cursor=... +// GET /api/profile/{username}/favourites?cursor=... +// GET /api/profile/{username}/stats +Route::middleware(['web', 'throttle:60,1']) + ->prefix('profile/{username}') + ->name('api.profile.') + ->where(['username' => '[A-Za-z0-9_-]{3,20}']) + ->group(function () { + Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks'); + Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites'); + Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats'); + }); + +// ── Link Preview (auth, throttled) ───────────────────────────────────────────── +// GET /api/link-preview?url=... → fetch OG tags for a URL + +Route::middleware(['web', 'auth', 'throttle:30,1']) + ->get('link-preview', \App\Http\Controllers\Api\LinkPreviewController::class) + ->name('api.link-preview'); + +// ── Posts / Feed System ─────────────────────────────────────────────────────── +// Public: profile feed (respects visibility) +// Auth : create/edit/delete posts, following feed, reactions, comments, reports, shares + +Route::middleware(['web', 'throttle:60,1']) + ->prefix('posts') + ->name('api.posts.') + ->group(function () { + // Profile feed (public, visibility-filtered) + Route::get('profile/{username}', [\App\Http\Controllers\Api\Posts\PostFeedController::class, 'profile']) + ->where('username', '[A-Za-z0-9_-]{3,20}') + ->name('profile'); + + // Per-post comments (public) + Route::get('{id}/comments', [\App\Http\Controllers\Api\Posts\PostCommentController::class, 'index']) + ->whereNumber('id') + ->name('comments.index'); + }); + +Route::middleware(['web', 'auth', 'normalize.username']) + ->prefix('posts') + ->name('api.posts.') + ->group(function () { + // Following feed (auth) + Route::get('following', [\App\Http\Controllers\Api\Posts\PostFeedController::class, 'following']) + ->middleware('throttle:60,1') + ->name('following'); + + // CRUD + Route::post('/', [\App\Http\Controllers\Api\Posts\PostController::class, 'store'])->name('store'); + Route::patch('{id}', [\App\Http\Controllers\Api\Posts\PostController::class, 'update'])->whereNumber('id')->name('update'); + Route::delete('{id}', [\App\Http\Controllers\Api\Posts\PostController::class, 'destroy'])->whereNumber('id')->name('destroy'); + + // Share artwork + Route::post('share/artwork/{artwork_id}', [\App\Http\Controllers\Api\Posts\PostShareController::class, 'shareArtwork']) + ->whereNumber('artwork_id') + ->middleware('throttle:30,1') + ->name('share.artwork'); + + // Reactions + Route::post('{id}/reactions', [\App\Http\Controllers\Api\Posts\PostReactionController::class, 'store']) + ->whereNumber('id') + ->middleware('throttle:60,1') + ->name('reactions.store'); + Route::delete('{id}/reactions/{reaction}', [\App\Http\Controllers\Api\Posts\PostReactionController::class, 'destroy']) + ->whereNumber('id') + ->middleware('throttle:60,1') + ->name('reactions.destroy'); + + // Comments + Route::post('{id}/comments', [\App\Http\Controllers\Api\Posts\PostCommentController::class, 'store'])->whereNumber('id')->name('comments.store'); + Route::delete('{id}/comments/{comment_id}', [\App\Http\Controllers\Api\Posts\PostCommentController::class, 'destroy'])->whereNumber(['id', 'comment_id'])->name('comments.destroy'); + + // Reports + Route::post('{id}/report', [\App\Http\Controllers\Api\Posts\PostReportController::class, 'store']) + ->whereNumber('id') + ->middleware('throttle:10,1') + ->name('report'); + + // ── Feed 2.0 ─────────────────────────────────────────────────────── + + // Pinned posts + Route::post('{id}/pin', [\App\Http\Controllers\Api\Posts\PostPinController::class, 'pin'])->whereNumber('id')->name('pin'); + Route::delete('{id}/pin', [\App\Http\Controllers\Api\Posts\PostPinController::class, 'unpin'])->whereNumber('id')->name('unpin'); + + // Saves / bookmarks + Route::post('{id}/save', [\App\Http\Controllers\Api\Posts\PostSaveController::class, 'save'])->whereNumber('id')->middleware('throttle:60,1')->name('save'); + Route::delete('{id}/save', [\App\Http\Controllers\Api\Posts\PostSaveController::class, 'unsave'])->whereNumber('id')->middleware('throttle:60,1')->name('unsave'); + Route::get('saved', [\App\Http\Controllers\Api\Posts\PostSaveController::class, 'index'])->middleware('throttle:60,1')->name('saved'); + + // Analytics + Route::post('{id}/impression', [\App\Http\Controllers\Api\Posts\PostAnalyticsController::class, 'impression'])->whereNumber('id')->middleware('throttle:120,1')->name('impression'); + Route::get('{id}/analytics', [\App\Http\Controllers\Api\Posts\PostAnalyticsController::class, 'show'])->whereNumber('id')->name('analytics'); + + // Comment highlight + Route::post('{post_id}/comments/{comment_id}/highlight', [\App\Http\Controllers\Api\Posts\PostCommentHighlightController::class, 'highlight'])->whereNumber(['post_id','comment_id'])->name('comments.highlight'); + Route::delete('{post_id}/comments/{comment_id}/highlight', [\App\Http\Controllers\Api\Posts\PostCommentHighlightController::class, 'unhighlight'])->whereNumber(['post_id','comment_id'])->name('comments.unhighlight'); + }); + +// ── Feed 2.0: Trending + Hashtag + Search (public) ─────────────────────────── +Route::middleware(['web', 'throttle:60,1']) + ->prefix('feed') + ->name('api.feed.') + ->group(function () { + Route::get('trending', [\App\Http\Controllers\Api\Posts\PostTrendingFeedController::class, 'trending'])->name('trending'); + Route::get('hashtag/{tag}', [\App\Http\Controllers\Api\Posts\PostTrendingFeedController::class, 'hashtag'])->name('hashtag'); + Route::get('hashtags/trending', [\App\Http\Controllers\Api\Posts\PostTrendingFeedController::class, 'trendingHashtags'])->name('hashtags.trending'); + Route::get('search', [\App\Http\Controllers\Api\Posts\PostSearchController::class, 'search'])->name('search'); + }); + +// ── Notifications (digest) ──────────────────────────────────────────────────── +Route::middleware(['web', 'auth']) + ->prefix('notifications') + ->name('api.notifications.') + ->group(function () { + Route::get('/', [\App\Http\Controllers\Api\NotificationController::class, 'index'])->middleware('throttle:30,1')->name('index'); + Route::post('read-all', [\App\Http\Controllers\Api\NotificationController::class, 'readAll'])->name('read-all'); + Route::post('{id}/read', [\App\Http\Controllers\Api\NotificationController::class, 'markRead'])->name('mark-read'); + }); + +// ── Artwork search for share modal (public, throttled) ──────────────────────── +// GET /api/search/artworks?q=...&shareable=1 → reuses existing ArtworkSearchController diff --git a/routes/console.php b/routes/console.php index 9c3716ea..1ee2d07b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -87,6 +87,20 @@ Schedule::job(new \App\Jobs\RecComputeSimilarHybridJob()) ->name('rec-compute-hybrid') ->withoutOverlapping(); +// ── Feed 2.0: Scheduled Posts ───────────────────────────────────────────────── +// Publish queued posts every minute. +Schedule::command('posts:publish-scheduled') + ->everyMinute() + ->name('publish-scheduled-posts') + ->withoutOverlapping(); + +// ── Feed 2.0: Trending Cache Warm-up ───────────────────────────────────────── +// Warm the post trending cache every 2 minutes (complements the 2-min TTL). +Schedule::command('posts:warm-trending') + ->everyTwoMinutes() + ->name('warm-post-trending') + ->withoutOverlapping(); + // ── Ranking Engine V2 ────────────────────────────────────────────────────────── // Recalculate ranking_score + engagement_velocity every 30 minutes. // Also syncs V2 scores to rank_artwork_scores so list builds benefit. diff --git a/routes/web.php b/routes/web.php index c4502934..333ed2e8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -398,3 +398,27 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n // ── 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'); diff --git a/tests/Feature/DashboardFavoritesTest.php b/tests/Feature/DashboardFavoritesTest.php index 41d976ec..f413d31b 100644 --- a/tests/Feature/DashboardFavoritesTest.php +++ b/tests/Feature/DashboardFavoritesTest.php @@ -35,11 +35,9 @@ class DashboardFavoritesTest extends TestCase $html = $response->getContent(); $this->assertNotFalse($html); - $this->assertStringContainsString('itemprop="thumbnailUrl"', $html); - $this->assertStringContainsString('data-blur-preview', $html); - $this->assertStringContainsString('loading="lazy"', $html); - $this->assertStringContainsString('decoding="async"', $html); - $this->assertMatchesRegularExpression('/]*data-blur-preview[^>]*/i', $html); + $this->assertStringContainsString('data-react-masonry-gallery', $html); + $this->assertStringContainsString('data-artworks=', $html); + $this->assertStringContainsString('data-gallery-type="dashboard-favorites"', $html); $this->actingAs($user) ->delete(route('dashboard.favorites.destroy', ['artwork' => $art->id])) diff --git a/tests/Feature/Posts/PostFeedTest.php b/tests/Feature/Posts/PostFeedTest.php new file mode 100644 index 00000000..32e244f5 --- /dev/null +++ b/tests/Feature/Posts/PostFeedTest.php @@ -0,0 +1,241 @@ + Artwork::factory()->create([ + 'is_public' => true, + 'is_approved' => true, + ...$attrs, + ])); +} + +beforeEach(function () { + if (DB::connection()->getDriverName() === 'sqlite') { + DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) { + return max($args); + }, -1); + } +}); + +// ── Post visibility scopes ───────────────────────────────────────────────────── + +test('public post is visible to everyone', function () { + $author = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'public']); + + expect(Post::visibleTo(null)->find($post->id))->not->toBeNull(); + expect(Post::visibleTo(999)->find($post->id))->not->toBeNull(); +}); + +test('followers-only post is hidden from non-followers', function () { + $author = User::factory()->create(); + $stranger = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'followers']); + + expect(Post::visibleTo($stranger->id)->find($post->id))->toBeNull(); +}); + +test('followers-only post is visible to followers', function () { + $author = User::factory()->create(); + $follower = User::factory()->create(); + DB::table('user_followers')->insert([ + 'user_id' => $author->id, + 'follower_id' => $follower->id, + 'created_at' => now(), + ]); + $post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'followers']); + + expect(Post::visibleTo($follower->id)->find($post->id))->not->toBeNull(); +}); + +test('private post is only visible to the author', function () { + $author = User::factory()->create(); + $other = User::factory()->create(); + $post = Post::factory()->create(['user_id' => $author->id, 'visibility' => 'private']); + + expect(Post::visibleTo(null)->find($post->id))->toBeNull(); + expect(Post::visibleTo($other->id)->find($post->id))->toBeNull(); + expect(Post::visibleTo($author->id)->find($post->id))->not->toBeNull(); +}); + +// ── PostShareService ─────────────────────────────────────────────────────────── + +test('shareArtwork creates a post with a target', function () { + $user = User::factory()->create(); + $artwork = postTestArtwork(['user_id' => $user->id]); + + $service = app(PostShareService::class); + $post = $service->shareArtwork($user, $artwork, 'Cool piece!', 'public'); + + expect($post)->toBeInstanceOf(Post::class) + ->and($post->type)->toBe('artwork_share') + ->and($post->body)->toBe('

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)