diff --git a/app/Console/Commands/ImportLegacyUsers.php b/app/Console/Commands/ImportLegacyUsers.php index 115a27be..10c77f23 100644 --- a/app/Console/Commands/ImportLegacyUsers.php +++ b/app/Console/Commands/ImportLegacyUsers.php @@ -223,11 +223,11 @@ class ImportLegacyUsers extends Command DB::table('user_statistics')->updateOrInsert( ['user_id' => $legacyId], [ - 'uploads' => $uploads, - 'downloads' => $downloads, - 'pageviews' => $pageviews, - 'awards' => $awards, - 'updated_at' => $now, + 'uploads_count' => $uploads, + 'downloads_received_count' => $downloads, + 'artwork_views_received_count' => $pageviews, + 'awards_received_count' => $awards, + 'updated_at' => $now, ] ); diff --git a/app/Console/Commands/MigrateFavourites.php b/app/Console/Commands/MigrateFavourites.php new file mode 100644 index 00000000..70ec24ec --- /dev/null +++ b/app/Console/Commands/MigrateFavourites.php @@ -0,0 +1,325 @@ +option('dry-run'); + $chunk = max(1, (int) $this->option('chunk')); + $startId = max(0, (int) $this->option('start-id')); + $limit = max(0, (int) $this->option('limit')); + + $this->importMissingUsers = (bool) $this->option('import-missing-users'); + $this->legacyConn = (string) $this->option('legacy-connection'); + $this->legacyUsersTable = (string) $this->option('legacy-users-table'); + $legacyTable = (string) $this->option('legacy-table'); + + $this->info("Migrating {$this->legacyConn}.{$legacyTable}artwork_favourites"); + + if ($this->importMissingUsers) { + $this->warn('--import-missing-users: stub users will be created with needs_password_reset=true.'); + } + + if ($dryRun) { + $this->warn('DRY-RUN mode — no rows will be written.'); + } + if ($startId > 0) { + $this->line("Resuming from favourite_id >= {$startId}"); + } + if ($limit > 0) { + $this->line("Will stop after {$limit} inserts."); + } + + $query = DB::connection($this->legacyConn) + ->table($legacyTable) + ->orderBy('favourite_id'); + + if ($startId > 0) { + $query->where('favourite_id', '>=', $startId); + } + + $query->chunkById( + $chunk, + function ($rows) use ($dryRun, $limit): bool { + foreach ($rows as $row) { + $this->total++; + + if ($limit > 0 && $this->inserted >= $limit) { + return false; // stop chunking + } + + if ($this->processRow($row, $dryRun) === false) { + $this->skipped++; + } + } + return true; + }, + 'favourite_id', + ); + + $this->newLine(); + $this->info(sprintf( + 'Done. %d scanned, %d %s, %d skipped%s.', + $this->total, + $this->inserted, + $dryRun ? 'would be inserted' : 'inserted', + $this->skipped, + $this->usersImported > 0 + ? ", {$this->usersImported} stub users " . ($dryRun ? 'would be ' : '') . 'created' + : '', + )); + + return self::SUCCESS; + } + + // ── Row processing ──────────────────────────────────────────────────────── + + /** + * Process a single legacy row. Returns true on success, false when skipped. + */ + private function processRow(object $row, bool $dryRun): bool + { + $legacyId = (int) ($row->favourite_id ?? 0); + $artworkId = (int) ($row->artwork_id ?? 0); + $userId = (int) ($row->user_id ?? 0); + $datum = $row->datum ?? null; + + // ── Validate IDs ──────────────────────────────────────────────────── + + if ($artworkId <= 0 || $userId <= 0) { + $this->skip($legacyId, "invalid artwork_id={$artworkId} or user_id={$userId}"); + return false; + } + + if (! DB::table('artworks')->where('id', $artworkId)->exists()) { + $this->skip($legacyId, "artwork #{$artworkId} not found in new DB"); + return false; + } + + if (! DB::table('users')->where('id', $userId)->exists()) { + if ($this->importMissingUsers) { + if (! $this->importUserStub($userId, $dryRun)) { + $this->skip($legacyId, "user #{$userId} not found in legacy DB either — skipped"); + return false; + } + } else { + $this->skip($legacyId, "user #{$userId} not found in new DB (use --import-missing-users to auto-create)"); + return false; + } + } + + // ── Idempotency guards ─────────────────────────────────────────────── + + if (DB::table('artwork_favourites')->where('legacy_id', $legacyId)->exists()) { + // Already imported — silently skip (not counted as "skipped" error) + return true; + } + + if (DB::table('artwork_favourites') + ->where('user_id', $userId) + ->where('artwork_id', $artworkId) + ->exists() + ) { + $this->skip($legacyId, "duplicate (user={$userId}, artwork={$artworkId}) already exists"); + return false; + } + + // ── Map timestamp ──────────────────────────────────────────────────── + + $createdAt = $this->parseDate($datum); + + // ── Insert ─────────────────────────────────────────────────────────── + + if (! $dryRun) { + DB::table('artwork_favourites')->insert([ + 'user_id' => $userId, + 'artwork_id' => $artworkId, + 'legacy_id' => $legacyId, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } + + $this->inserted++; + + if ($this->inserted % 500 === 0) { + $this->line(" {$this->inserted} inserted, {$this->skipped} skipped…"); + } + + return true; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Look up $userId in the legacy users table and create a stub record in + * the new users table preserving the same primary key. + * + * The stub has: + * - needs_password_reset = true (user must reset before logging in) + * - legacy_password_algo = 'legacy' (marks imported credential) + * - is_active determined from legacy `active` flag + * - email placeholder if original email is null or already taken + * + * @return bool true = stub created (or already existed), false = not in legacy DB + */ + private function importUserStub(int $userId, bool $dryRun): bool + { + // Already exists — nothing to do. + if (DB::table('users')->where('id', $userId)->exists()) { + return true; + } + + $legacyUser = DB::connection($this->legacyConn) + ->table($this->legacyUsersTable) + ->where('user_id', $userId) + ->first(); + + if (! $legacyUser) { + return false; + } + + // ── Map fields ────────────────────────────────────────────────────── + + $username = trim((string) ($legacyUser->uname ?? '')) ?: "user_{$userId}"; + + // Ensure username is unique in the new DB. + if (DB::table('users')->where('username', $username)->exists()) { + $username = $username . '_' . $userId; + } + + $name = trim((string) ($legacyUser->real_name ?? '')) ?: $username; + $email = trim((string) ($legacyUser->email ?? '')); + + // Resolve email: use placeholder when blank or already taken. + if ($email === '' || DB::table('users')->where('email', $email)->exists()) { + $email = "legacy_{$userId}@legacy.skinbase.org"; + } + + $isActive = ((int) ($legacyUser->active ?? 0)) === 1; + $createdAt = $this->parseDate($legacyUser->joinDate ?? null); + $lastVisit = $this->parseDate($legacyUser->LastVisit ?? null); + + $stub = [ + 'id' => $userId, + 'username' => $username, + 'name' => $name, + 'email' => $email, + 'password' => bcrypt(Str::random(48)), // unusable random password + 'needs_password_reset' => true, + 'legacy_password_algo' => 'legacy', + 'is_active' => $isActive, + 'role' => 'user', + 'last_visit_at' => $lastVisit !== $createdAt ? $lastVisit : null, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]; + + $msg = "Stub user created: #{$userId} ({$username}, {$email})"; + + if ($dryRun) { + $this->line(" [dry] {$msg}"); + $this->usersImported++; + return true; + } + + try { + // Force explicit ID insert — MySQL respects it even with auto_increment. + DB::table('users')->insert($stub); + $this->usersImported++; + $this->line(" {$msg}"); + Log::info("skinbase:migrate-favourites {$msg}"); + } catch (\Throwable $e) { + $err = "Failed to create stub user #{$userId}: {$e->getMessage()}"; + $this->warn(" {$err}"); + Log::error("skinbase:migrate-favourites {$err}"); + return false; + } + + return true; + } + + /** + * Parse a legacy date value (DATE string / null / zero-date) to a + * full datetime string safe for MySQL. + */ + private function parseDate(mixed $value): string + { + if (empty($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') { + return Carbon::now()->toDateTimeString(); + } + + try { + return Carbon::parse((string) $value)->toDateTimeString(); + } catch (\Throwable) { + return Carbon::now()->toDateTimeString(); + } + } + + private function skip(int $legacyId, string $reason): void + { + $msg = "SKIP favourite#{$legacyId}: {$reason}"; + $this->warn(" {$msg}"); + Log::warning("skinbase:migrate-favourites {$msg}"); + } +} diff --git a/app/Console/Commands/MigrateFollows.php b/app/Console/Commands/MigrateFollows.php new file mode 100644 index 00000000..6bbfbe4a --- /dev/null +++ b/app/Console/Commands/MigrateFollows.php @@ -0,0 +1,351 @@ + follower_id (the user who added the friend = someone who follows) + * friend_id -> user_id (the user being followed) + * + * With --import-missing-users: any user referenced in friends_list that does not + * exist in the new DB will be fetched from the legacy `users` table and created + * as a stub before the follow row is inserted. + */ +class MigrateFollows extends Command +{ + protected $signature = 'skinbase:migrate-follows + {--dry-run : Simulate without writing to the database} + {--chunk=1000 : Number of rows to process per batch} + {--import-missing-users : Import unknown users from legacy DB instead of skipping them}'; + + protected $description = 'Migrate legacy friends_list into user_followers'; + + /** Cache per-run: id => true (resolved) | null (not in legacy DB) | false (import error) */ + private array $legacyUserCache = []; + + public function handle(): int + { + $isDryRun = (bool) $this->option('dry-run'); + $chunkSize = max(1, (int) $this->option('chunk')); + $importMissing = (bool) $this->option('import-missing-users'); + + $this->info($isDryRun + ? '🔍 Dry-run mode – nothing will be written.' + : '🚀 Live mode – writing to user_followers.' + ); + if ($importMissing) { + $this->info('👤 --import-missing-users: orphan users will be fetched from legacy DB.'); + } + + try { + $totalLegacy = DB::connection('legacy')->table('friends_list')->count(); + } catch (\Throwable $e) { + $this->error('Cannot read legacy friends_list: ' . $e->getMessage()); + return self::FAILURE; + } + + $this->info("Total rows in legacy friends_list: {$totalLegacy}"); + + $validUserIds = DB::table('users')->pluck('id')->flip()->all(); + + $stats = [ + 'processed' => 0, + 'inserted' => 0, + 'duplicates' => 0, + 'self_follows' => 0, + 'invalid' => 0, // total orphan rows skipped + 'invalid_zero_id' => 0, // follower_id or friend_id was 0 + 'invalid_not_in_new' => 0, // not in new DB (--import-missing-users not used) + 'invalid_not_in_legacy' => 0, // not in new DB AND not in legacy DB + 'invalid_import_error' => 0, // in legacy DB but stub import failed + 'users_imported' => 0, + 'errors' => 0, + ]; + + $logPath = storage_path('logs/migrate_follows.log'); + $logFile = fopen($logPath, 'a'); + $this->logLine($logFile, '=== migrate-follows started at ' . now()->toISOString() + . " (dry_run={$isDryRun}, import_missing={$importMissing}) ==="); + + $chunkNum = 0; + $reportEvery = max(1, (int) ceil($totalLegacy / $chunkSize / 10)); + + DB::connection('legacy') + ->table('friends_list') + ->orderBy('id') + ->chunk($chunkSize, function ($rows) use ( + $isDryRun, + $importMissing, + &$validUserIds, + &$stats, + &$chunkNum, + $reportEvery, + $totalLegacy, + $logFile + ) { + $toInsert = []; + + foreach ($rows as $row) { + $stats['processed']++; + + $followerId = (int) ($row->user_id ?? 0); + $followedId = (int) ($row->friend_id ?? 0); + $createdAt = $row->date_added ?? now(); + + if ($followerId === $followedId) { + $stats['self_follows']++; + $this->logLine($logFile, "SKIP self-follow: user_id={$followerId}"); + continue; + } + + // Try to resolve any user_id that isn't in the new DB yet + $skipReasons = []; + $sides = ['follower' => $followerId, 'followed' => $followedId]; + + foreach ($sides as $role => $uid) { + if (isset($validUserIds[$uid])) { + continue; // already valid + } + + if ($uid === 0) { + $skipReasons[] = "{$role}_id is 0/null"; + $stats['invalid_zero_id']++; + continue; + } + + if (! $importMissing) { + $skipReasons[] = "{$role}={$uid} not in users table (use --import-missing-users to auto-import)"; + $stats['invalid_not_in_new']++; + continue; + } + + // ensureLegacyUser returns: true = resolved, null = not in legacy, false = import error + $result = $this->ensureLegacyUser($uid, $isDryRun, $logFile); + if ($result === true) { + $validUserIds[$uid] = true; + $stats['users_imported']++; + } elseif ($result === null) { + $skipReasons[] = "{$role}={$uid} not found in legacy DB"; + $stats['invalid_not_in_legacy']++; + } else { + $skipReasons[] = "{$role}={$uid} found in legacy DB but import failed"; + $stats['invalid_import_error']++; + } + } + + if (! isset($validUserIds[$followerId]) || ! isset($validUserIds[$followedId])) { + $stats['invalid']++; + $reason = implode('; ', $skipReasons) ?: 'unknown'; + $this->logLine($logFile, "SKIP orphan [row_id={$row->id}] follower={$followerId} followed={$followedId} — {$reason}"); + continue; + } + + $toInsert[] = [ + 'follower_id' => $followerId, + 'user_id' => $followedId, + 'created_at' => $createdAt, + ]; + } + + if (! $isDryRun && ! empty($toInsert)) { + try { + $inserted = DB::table('user_followers')->insertOrIgnore($toInsert); + $stats['inserted'] += $inserted; + $stats['duplicates'] += count($toInsert) - $inserted; + } catch (\Throwable $e) { + $stats['errors']++; + $this->logLine($logFile, 'ERROR batch insert: ' . $e->getMessage()); + } + } elseif ($isDryRun) { + $stats['inserted'] += count($toInsert); + } + + $chunkNum++; + if ($chunkNum % $reportEvery === 0 || $stats['processed'] >= $totalLegacy) { + $pct = $totalLegacy > 0 ? round($stats['processed'] / $totalLegacy * 100) : 100; + $this->line(" {$stats['processed']} / {$totalLegacy} rows ({$pct}%)" + . " inserted: {$stats['inserted']}" + . " imported: {$stats['users_imported']}" + . " skipped: " . ($stats['self_follows'] + $stats['invalid'])); + } + }); + + $this->newLine(); + + if (! $isDryRun) { + $this->info('Backfilling user_statistics counters...'); + $this->backfillCounters(); + } + + $this->table( + ['Metric', 'Count'], + [ + ['Processed', $stats['processed']], + ['Inserted', $stats['inserted']], + ['Duplicates (already exist)', $stats['duplicates']], + ['Self-follows skipped', $stats['self_follows']], + ['Users stub-imported from legacy', $stats['users_imported']], + ['Invalid (orphan) — total', $stats['invalid']], + [' ↳ zero/null user_id', $stats['invalid_zero_id']], + [' ↳ not in new DB (not imported)', $stats['invalid_not_in_new']], + [' ↳ not in legacy DB either', $stats['invalid_not_in_legacy']], + [' ↳ legacy import error', $stats['invalid_import_error']], + ['Errors', $stats['errors']], + ] + ); + + $summary = "Processed={$stats['processed']} Inserted={$stats['inserted']} " + . "Duplicates={$stats['duplicates']} SelfFollows={$stats['self_follows']} " + . "UsersImported={$stats['users_imported']} Invalid={$stats['invalid']} " + . "(ZeroId={$stats['invalid_zero_id']} NotInNew={$stats['invalid_not_in_new']} " + . "NotInLegacy={$stats['invalid_not_in_legacy']} ImportError={$stats['invalid_import_error']}) " + . "Errors={$stats['errors']}"; + + $this->logLine($logFile, "=== DONE: {$summary} ==="); + fclose($logFile); + + $this->info("Log written to: {$logPath}"); + + return self::SUCCESS; + } + + // ------------------------------------------------------------------------- + + /** + * Ensure a legacy user_id exists in the new `users` table. + * + * Returns: + * true – user is valid (was already there, or was just imported / dry-run pretend-imported) + * null – user not found in the legacy DB either → cannot be imported + * false – user found in legacy DB but the stub-import threw an exception + * + * Results are cached per command run to avoid redundant DB queries. + */ + private function ensureLegacyUser(int $legacyId, bool $isDryRun, $logFile): ?bool + { + if (array_key_exists($legacyId, $this->legacyUserCache)) { + return $this->legacyUserCache[$legacyId]; + } + + if (DB::table('users')->where('id', $legacyId)->exists()) { + return $this->legacyUserCache[$legacyId] = true; + } + + $legacyUser = DB::connection('legacy') + ->table('users') + ->where('user_id', $legacyId) + ->first(); + + if (! $legacyUser) { + $this->logLine($logFile, "IMPORT FAIL: user_id={$legacyId} not found in legacy DB"); + return $this->legacyUserCache[$legacyId] = null; + } + + if ($isDryRun) { + $this->logLine($logFile, "DRY-RUN IMPORT: would create user_id={$legacyId} uname={$legacyUser->uname}"); + return $this->legacyUserCache[$legacyId] = true; + } + + try { + $this->importLegacyUserStub($legacyUser); + $this->logLine($logFile, "IMPORTED user_id={$legacyId} uname={$legacyUser->uname}"); + return $this->legacyUserCache[$legacyId] = true; + } catch (\Throwable $e) { + $this->logLine($logFile, "IMPORT ERROR user_id={$legacyId}: " . $e->getMessage()); + return $this->legacyUserCache[$legacyId] = false; + } + } + + private function importLegacyUserStub(object $row): void + { + $legacyId = (int) $row->user_id; + $now = now(); + + $username = UsernamePolicy::sanitizeLegacy((string) ($row->uname ?: ('user' . $legacyId))); + if (! $username) { + $username = 'user' . $legacyId; + } + + if (DB::table('users')->whereRaw('LOWER(username) = ?', [strtolower($username)])->exists()) { + $username = $username . $legacyId; + } + + $email = ($row->email ? strtolower(trim($row->email)) : null) + ?: ('user' . $legacyId . '@users.skinbase.org'); + + DB::transaction(function () use ($legacyId, $username, $email, $row, $now) { + DB::table('users')->insertOrIgnore([ + 'id' => $legacyId, + 'username' => $username, + 'name' => $row->real_name ?: $username, + 'email' => $email, + 'password' => Hash::make(Str::random(32)), + 'is_active' => (int) ($row->active ?? 1) === 1, + 'needs_password_reset' => true, + 'role' => 'user', + 'created_at' => $row->joinDate ?? $now, + 'updated_at' => $now, + ]); + + DB::table('user_profiles')->updateOrInsert( + ['user_id' => $legacyId], + [ + 'country' => $row->country ?? null, + 'country_code' => $row->country_code ? substr((string) $row->country_code, 0, 2) : null, + 'website' => $row->web ?? null, + 'updated_at' => $now, + ] + ); + + DB::table('user_statistics')->updateOrInsert( + ['user_id' => $legacyId], + ['updated_at' => $now, 'created_at' => $now] + ); + }); + } + + // ------------------------------------------------------------------------- + + private function backfillCounters(): void + { + DB::statement(' + UPDATE user_statistics us + JOIN ( + SELECT user_id, COUNT(*) AS cnt + FROM user_followers + GROUP BY user_id + ) AS f ON f.user_id = us.user_id + SET us.followers_count = f.cnt, us.updated_at = NOW() + '); + + DB::statement(' + UPDATE user_statistics us + JOIN ( + SELECT follower_id, COUNT(*) AS cnt + FROM user_followers + GROUP BY follower_id + ) AS f ON f.follower_id = us.user_id + SET us.following_count = f.cnt, us.updated_at = NOW() + '); + + $this->info('Counters backfilled.'); + } + + private function logLine($handle, string $message): void + { + if (is_resource($handle)) { + fwrite($handle, '[' . now()->toISOString() . '] ' . $message . PHP_EOL); + } + } +} diff --git a/app/Console/Commands/MigrateMessagesCommand.php b/app/Console/Commands/MigrateMessagesCommand.php new file mode 100644 index 00000000..7542ecb6 --- /dev/null +++ b/app/Console/Commands/MigrateMessagesCommand.php @@ -0,0 +1,246 @@ +option('dry-run'); + $chunk = max(1, (int) $this->option('chunk')); + + if ($dryRun) { + $this->warn('[DRY-RUN] No data will be written.'); + } + + // ── Check legacy connection ─────────────────────────────────────────── + try { + DB::connection('legacy')->getPdo(); + } catch (Throwable $e) { + $this->error('Cannot connect to legacy database: ' . $e->getMessage()); + return self::FAILURE; + } + + $legacySchema = DB::connection('legacy')->getSchemaBuilder(); + + if (! $legacySchema->hasTable('chat')) { + $this->error('Legacy table `chat` not found on the legacy connection.'); + return self::FAILURE; + } + + $columns = $legacySchema->getColumnListing('chat'); + $this->info('Legacy chat columns: ' . implode(', ', $columns)); + + // Map expected legacy columns (adapt if your legacy schema differs) + $hasReadDate = in_array('read_date', $columns, true); + $hasSoftDelete = in_array('deleted', $columns, true); + + // ── Count total rows ────────────────────────────────────────────────── + $query = DB::connection('legacy')->table('chat'); + + if ($hasSoftDelete) { + $query->where('deleted', 0); + } + + $total = $query->count(); + $this->info("Total legacy rows to process: {$total}"); + + if ($total === 0) { + $this->info('Nothing to migrate.'); + return self::SUCCESS; + } + + $bar = $this->output->createProgressBar($total); + $inserted = 0; + $skipped = 0; + $offset = 0; + + // ── Chunk processing ────────────────────────────────────────────────── + while (true) { + $rows = DB::connection('legacy') + ->table('chat') + ->when($hasSoftDelete, fn ($q) => $q->where('deleted', 0)) + ->orderBy('id') + ->offset($offset) + ->limit($chunk) + ->get(); + + if ($rows->isEmpty()) { + break; + } + + foreach ($rows as $row) { + $senderId = (int) ($row->sender_user_id ?? $row->from_user_id ?? $row->user_id ?? 0); + $receiverId = (int) ($row->receiver_user_id ?? $row->to_user_id ?? $row->recipient_id ?? 0); + $body = trim((string) ($row->message ?? $row->body ?? $row->content ?? '')); + $createdAt = $row->created_at ?? $row->date ?? $row->timestamp ?? now(); + $readDate = $hasReadDate ? $row->read_date : null; + + if ($senderId === 0 || $receiverId === 0 || $body === '') { + $skipped++; + $this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'missing sender/receiver/body']; + $bar->advance(); + continue; + } + + // Skip self-messages + if ($senderId === $receiverId) { + $skipped++; + $this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'self-message']; + $bar->advance(); + continue; + } + + // Sanitize: strip HTML, convert smileys to emoji + $body = $this->sanitize($body); + + if ($dryRun) { + $inserted++; + $bar->advance(); + continue; + } + + try { + DB::transaction(function () use ($senderId, $receiverId, $body, $createdAt, $readDate, &$inserted) { + // Find or create direct conversation + $conv = Conversation::findDirect($senderId, $receiverId); + + if (! $conv) { + $conv = Conversation::create([ + 'type' => 'direct', + 'created_by' => $senderId, + 'last_message_at' => $createdAt, + ]); + + ConversationParticipant::insert([ + [ + 'conversation_id' => $conv->id, + 'user_id' => $senderId, + 'role' => 'admin', + 'joined_at' => $createdAt, + 'last_read_at' => $readDate, + ], + [ + 'conversation_id' => $conv->id, + 'user_id' => $receiverId, + 'role' => 'member', + 'joined_at' => $createdAt, + 'last_read_at' => $readDate, + ], + ]); + } else { + // Update last_read_at on existing participants when available + if ($readDate) { + ConversationParticipant::where('conversation_id', $conv->id) + ->where('user_id', $receiverId) + ->whereNull('last_read_at') + ->update(['last_read_at' => $readDate]); + } + } + + Message::create([ + 'conversation_id' => $conv->id, + 'sender_id' => $senderId, + 'body' => $body, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + + // Keep last_message_at up to date + if ($conv->last_message_at < $createdAt) { + $conv->update(['last_message_at' => $createdAt]); + } + + $inserted++; + }); + } catch (Throwable $e) { + $skipped++; + $this->skipped[] = ['id' => $row->id ?? '?', 'reason' => $e->getMessage()]; + Log::warning('MigrateMessages: skipped row', [ + 'id' => $row->id ?? '?', + 'reason' => $e->getMessage(), + ]); + } + + $bar->advance(); + } + + $offset += $chunk; + } + + $bar->finish(); + $this->newLine(); + + $this->info("Done. Inserted: {$inserted} | Skipped: {$skipped}"); + + if ($skipped > 0 && $this->option('verbose')) { + $this->table(['ID', 'Reason'], $this->skipped); + } + + return self::SUCCESS; + } + + /** + * Strip HTML tags and convert common legacy smileys to emoji. + */ + private function sanitize(string $body): string + { + // Strip raw HTML + $body = strip_tags($body); + + // Decode HTML entities + $body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Common smiley → emoji mapping + $smileys = [ + ':)' => '🙂', ':-)' => '🙂', + ':(' => '🙁', ':-(' => '🙁', + ':D' => '😀', ':-D' => '😀', + ':P' => '😛', ':-P' => '😛', + ';)' => '😉', ';-)' => '😉', + ':o' => '😮', ':O' => '😮', + ':|' => '😐', ':-|' => '😐', + ':/' => '😕', ':-/' => '😕', + '<3' => '❤️', + 'xD' => '😂', 'XD' => '😂', + ]; + + return str_replace(array_keys($smileys), array_values($smileys), $body); + } +} diff --git a/app/Console/Commands/MigrateSmileys.php b/app/Console/Commands/MigrateSmileys.php new file mode 100644 index 00000000..befdf666 --- /dev/null +++ b/app/Console/Commands/MigrateSmileys.php @@ -0,0 +1,143 @@ + 'description', + 'artwork_comments' => 'content', + 'forum_posts' => 'content', + ]; + + public function handle(): int + { + $dryRun = (bool) $this->option('dry-run'); + $chunk = max(1, (int) $this->option('chunk')); + $tableOpt = $this->option('table'); + + $targets = self::TARGETS; + if ($tableOpt) { + if (! isset($targets[$tableOpt])) { + $this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets))); + return self::FAILURE; + } + $targets = [$tableOpt => $targets[$tableOpt]]; + } + + if ($dryRun) { + $this->warn('DRY-RUN mode — no changes will be written.'); + } + + $totalChanged = 0; + $totalRows = 0; + + foreach ($targets as $table => $column) { + $this->line("Scanning {$table}.{$column}…"); + + [$changed, $rows] = $this->processTable($table, $column, $chunk, $dryRun); + + $totalChanged += $changed; + $totalRows += $rows; + + $this->line(" → {$rows} rows scanned, {$changed} updated."); + } + + $this->newLine(); + $this->info("Summary: {$totalRows} rows scanned, {$totalChanged} rows " . ($dryRun ? 'would be ' : '') . 'updated.'); + + return self::SUCCESS; + } + + private function processTable( + string $table, + string $column, + int $chunk, + bool $dryRun + ): array { + $totalChanged = 0; + $totalRows = 0; + + DB::table($table) + ->whereNotNull($column) + ->orderBy('id') + ->chunk($chunk, function ($rows) use ($table, $column, $dryRun, &$totalChanged, &$totalRows) { + foreach ($rows as $row) { + $original = $row->$column ?? ''; + $converted = LegacySmileyMapper::convert($original); + + // Collapse emoji flood runs BEFORE size/DB checks so that + // rows like ":beer :beer :beer …" (×500) don't exceed MEDIUMTEXT. + $collapsed = LegacySmileyMapper::collapseFlood($converted); + if ($collapsed !== $converted) { + $beforeBytes = mb_strlen($converted, '8bit'); + $afterBytes = mb_strlen($collapsed, '8bit'); + $floodMsg = "[{$table}#{$row->id}] Emoji flood collapsed " + . "({$beforeBytes} bytes \u{2192} {$afterBytes} bytes)."; + $this->warn(" {$floodMsg}"); + Log::warning($floodMsg); + $converted = $collapsed; + } + + $totalRows++; + + if ($converted === $original) { + continue; + } + + $totalChanged++; + + $codes = LegacySmileyMapper::detect($original); + $msg = "[{$table}#{$row->id}] Converting: " . implode(', ', $codes); + $this->line(" {$msg}"); + Log::info($msg); + + if (! $dryRun) { + // Guard: MEDIUMTEXT max is 16,777,215 bytes. + if (mb_strlen($converted, '8bit') > 16_777_215) { + $warn = "[{$table}#{$row->id}] SKIP — converted content exceeds MEDIUMTEXT limit (" . mb_strlen($converted, '8bit') . " bytes). Row left unchanged."; + $this->warn(" {$warn}"); + Log::warning($warn); + continue; + } + + try { + DB::table($table) + ->where('id', $row->id) + ->update([$column => $converted]); + } catch (\Throwable $e) { + $err = "[{$table}#{$row->id}] DB error: {$e->getMessage()}"; + $this->warn(" {$err}"); + Log::error($err); + } + } + } + }); + + return [$totalChanged, $totalRows]; + } +} diff --git a/app/Console/Commands/RecomputeUserStatsCommand.php b/app/Console/Commands/RecomputeUserStatsCommand.php new file mode 100644 index 00000000..708fa770 --- /dev/null +++ b/app/Console/Commands/RecomputeUserStatsCommand.php @@ -0,0 +1,147 @@ +option('dry-run'); + $all = (bool) $this->option('all'); + $userId = $this->argument('user_id'); + $chunk = max(1, (int) $this->option('chunk')); + $queue = (bool) $this->option('queue'); + + if ($userId !== null && $all) { + $this->error('Provide either a user_id OR --all, not both.'); + return self::FAILURE; + } + + if ($userId !== null) { + return $this->recomputeSingle((int) $userId, $statsService, $dryRun); + } + + if ($all) { + return $this->recomputeAll($statsService, $chunk, $dryRun, $queue); + } + + $this->error('Provide a user_id or use --all.'); + return self::FAILURE; + } + + // ─── Single user ───────────────────────────────────────────────────────── + + private function recomputeSingle(int $userId, UserStatsService $statsService, bool $dryRun): int + { + $exists = DB::table('users')->where('id', $userId)->exists(); + if (! $exists) { + $this->error("User {$userId} not found."); + return self::FAILURE; + } + + $label = $dryRun ? '[DRY-RUN]' : '[LIVE]'; + $this->line("{$label} Recomputing stats for user #{$userId}…"); + + $computed = $statsService->recomputeUser($userId, $dryRun); + + $rows = []; + foreach ($computed as $col => $val) { + $rows[] = [$col, $val ?? '(null)']; + } + + $this->table(['Column', 'Value'], $rows); + + if ($dryRun) { + $this->warn('Dry-run: no changes written.'); + } else { + $this->info("Stats saved for user #{$userId}."); + } + + return self::SUCCESS; + } + + // ─── All users ──────────────────────────────────────────────────────────── + + private function recomputeAll( + UserStatsService $statsService, + int $chunk, + bool $dryRun, + bool $useQueue + ): int { + $total = DB::table('users')->whereNull('deleted_at')->count(); + $label = $dryRun ? '[DRY-RUN]' : ($useQueue ? '[QUEUE]' : '[LIVE]'); + + $this->info("{$label} Recomputing stats for {$total} users (chunk={$chunk})…"); + + if ($useQueue && ! $dryRun) { + $dispatched = 0; + DB::table('users') + ->whereNull('deleted_at') + ->orderBy('id') + ->chunkById($chunk, function ($users) use (&$dispatched) { + $ids = $users->pluck('id')->all(); + RecomputeUserStatsJob::dispatch($ids); + $dispatched += count($ids); + $this->line(" Queued chunk of " . count($ids) . " users (total dispatched: {$dispatched})"); + }); + + $this->info("Done – {$dispatched} users queued for recompute."); + return self::SUCCESS; + } + + $processed = 0; + $bar = $this->output->createProgressBar($total); + $bar->start(); + + DB::table('users') + ->whereNull('deleted_at') + ->orderBy('id') + ->chunkById($chunk, function ($users) use ($statsService, $dryRun, &$processed, $bar) { + foreach ($users as $user) { + $statsService->recomputeUser((int) $user->id, $dryRun); + $processed++; + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(); + + $suffix = $dryRun ? ' (no changes written – dry-run)' : ''; + $this->info("Done – {$processed} users recomputed{$suffix}."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SanitizeContent.php b/app/Console/Commands/SanitizeContent.php new file mode 100644 index 00000000..de91d9cf --- /dev/null +++ b/app/Console/Commands/SanitizeContent.php @@ -0,0 +1,188 @@ + [read_col, write_raw_col, write_rendered_col|null] + * + * For artwork_comments we write two columns; for the others we only sanitize in-place. + */ + private const TARGETS = [ + 'artwork_comments' => [ + 'read' => 'content', + 'write_raw' => 'raw_content', + 'write_rendered' => 'rendered_content', + ], + 'artworks' => [ + 'read' => 'description', + 'write_raw' => 'description', + 'write_rendered' => null, + ], + 'forum_posts' => [ + 'read' => 'content', + 'write_raw' => 'content', + 'write_rendered' => null, + ], + ]; + + public function handle(): int + { + $dryRun = (bool) $this->option('dry-run'); + $chunk = max(1, (int) $this->option('chunk')); + $tableOpt = $this->option('table'); + $artworkId = $this->option('artwork-id'); + + if ($artworkId !== null) { + if (! ctype_digit((string) $artworkId) || (int) $artworkId < 1) { + $this->error("--artwork-id must be a positive integer. Got: {$artworkId}"); + return self::FAILURE; + } + $artworkId = (int) $artworkId; + } + + $targets = self::TARGETS; + if ($tableOpt) { + if (! isset($targets[$tableOpt])) { + $this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets))); + return self::FAILURE; + } + $targets = [$tableOpt => $targets[$tableOpt]]; + } + + // --artwork-id removes forum_posts (no artwork FK) and informs the user. + if ($artworkId !== null) { + unset($targets['forum_posts']); + $this->line("Filtering to artwork #{$artworkId} (forum_posts skipped)."); + } + + if ($dryRun) { + $this->warn('DRY-RUN mode — no changes will be written.'); + } + + $totalModified = 0; + $totalRows = 0; + + foreach ($targets as $table => $def) { + $this->line("Processing {$table}…"); + + [$modified, $rows] = $this->processTable($table, $def, $chunk, $dryRun, $artworkId); + $totalModified += $modified; + $totalRows += $rows; + + $this->line(" → {$rows} rows scanned, {$modified} modified."); + } + + $this->newLine(); + $this->info("Summary: {$totalRows} rows, {$totalModified} " . ($dryRun ? 'would be ' : '') . 'modified.'); + + return self::SUCCESS; + } + + private function processTable( + string $table, + array $def, + int $chunk, + bool $dryRun, + ?int $artworkId = null + ): array { + $totalModified = 0; + $totalRows = 0; + + $readCol = $def['read']; + $writeRawCol = $def['write_raw']; + $writeRenderedCol = $def['write_rendered']; + + DB::table($table) + ->whereNotNull($readCol) + ->when($artworkId !== null, function ($q) use ($table, $artworkId) { + // artwork_comments has artwork_id; artworks is filtered by its own PK. + $filterCol = $table === 'artwork_comments' ? 'artwork_id' : 'id'; + $q->where($filterCol, $artworkId); + }) + ->orderBy('id') + ->chunk($chunk, function ($rows) use ( + $table, $readCol, $writeRawCol, $writeRenderedCol, + $dryRun, &$totalModified, &$totalRows + ) { + foreach ($rows as $row) { + $original = $row->$readCol ?? ''; + $stripped = ContentSanitizer::stripToPlain($original); + + $totalRows++; + + // Detect if content had HTML that we need to clean + $hadHtml = $original !== $stripped && preg_match('/<[a-z][^>]*>/i', $original); + + if ($writeRawCol === $readCol && ! $hadHtml) { + // Same column, no HTML, skip + continue; + } + + $rendered = ContentSanitizer::render($stripped); + $totalModified++; + + if ($hadHtml) { + $this->line(" [{$table}#{$row->id}] Stripped HTML from content."); + Log::info("skinbase:sanitize-content stripped HTML from {$table}#{$row->id}"); + } + + if ($dryRun) { + continue; + } + + $update = [$writeRawCol => $stripped]; + + if ($writeRenderedCol) { + $update[$writeRenderedCol] = $rendered; + } + + DB::table($table)->where('id', $row->id)->update($update); + } + + // Also populate rendered_content for rows that have raw_content but no rendered_content + if ($writeRenderedCol && ! $dryRun) { + DB::table($table) + ->whereNotNull($writeRawCol) + ->whereNull($writeRenderedCol) + ->orderBy('id') + ->chunk(200, function ($missing) use ($table, $writeRawCol, $writeRenderedCol) { + foreach ($missing as $row) { + $rendered = ContentSanitizer::render($row->$writeRawCol ?? ''); + DB::table($table)->where('id', $row->id)->update([ + $writeRenderedCol => $rendered, + ]); + } + }); + } + }); + + return [$totalModified, $totalRows]; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 42f5ee40..1d08be83 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -35,6 +35,7 @@ class Kernel extends ConsoleKernel EvaluateFeedWeightsCommand::class, CompareFeedAbCommand::class, AiTagArtworksCommand::class, + \App\Console\Commands\MigrateFollows::class, ]; /** diff --git a/app/Enums/ReactionType.php b/app/Enums/ReactionType.php new file mode 100644 index 00000000..12f05085 --- /dev/null +++ b/app/Enums/ReactionType.php @@ -0,0 +1,63 @@ + '👍', + self::Heart => '❤️', + self::Fire => '🔥', + self::Laugh => '😂', + self::Clap => '👏', + self::Wow => '😮', + }; + } + + /** Human-readable label. */ + public function label(): string + { + return match ($this) { + self::ThumbsUp => 'Like', + self::Heart => 'Love', + self::Fire => 'Fire', + self::Laugh => 'Haha', + self::Clap => 'Clap', + self::Wow => 'Wow', + }; + } + + /** All valid slugs — used for validation. */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** Full UI payload for the frontend. */ + public static function asMap(): array + { + $map = []; + foreach (self::cases() as $case) { + $map[$case->value] = [ + 'slug' => $case->value, + 'emoji' => $case->emoji(), + 'label' => $case->label(), + ]; + } + return $map; + } +} diff --git a/app/Events/MessageSent.php b/app/Events/MessageSent.php new file mode 100644 index 00000000..56bbdf9e --- /dev/null +++ b/app/Events/MessageSent.php @@ -0,0 +1,19 @@ +query('status', 'open'); + $status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open'; + + $items = Report::query() + ->with('reporter:id,username') + ->where('status', $status) + ->orderByDesc('id') + ->paginate(30); + + return response()->json($items); + } +} diff --git a/app/Http/Controllers/Api/ArtworkCommentController.php b/app/Http/Controllers/Api/ArtworkCommentController.php new file mode 100644 index 00000000..ba08ad62 --- /dev/null +++ b/app/Http/Controllers/Api/ArtworkCommentController.php @@ -0,0 +1,178 @@ +published()->findOrFail($artworkId); + + $page = max(1, (int) $request->query('page', 1)); + $perPage = 20; + + $comments = ArtworkComment::with(['user', 'user.profile']) + ->where('artwork_id', $artwork->id) + ->where('is_approved', true) + ->orderByDesc('created_at') + ->paginate($perPage, ['*'], 'page', $page); + + $userId = $request->user()?->id; + $items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId)); + + return response()->json([ + 'data' => $items, + 'meta' => [ + 'current_page' => $comments->currentPage(), + 'last_page' => $comments->lastPage(), + 'total' => $comments->total(), + 'per_page' => $comments->perPage(), + ], + ]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Store + // ───────────────────────────────────────────────────────────────────────── + + public function store(Request $request, int $artworkId): JsonResponse + { + $artwork = Artwork::public()->published()->findOrFail($artworkId); + + $request->validate([ + 'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH], + ]); + + $raw = $request->input('content'); + + // Validate markdown-lite content + $errors = ContentSanitizer::validate($raw); + if ($errors) { + return response()->json(['errors' => ['content' => $errors]], 422); + } + + $rendered = ContentSanitizer::render($raw); + + $comment = ArtworkComment::create([ + 'artwork_id' => $artwork->id, + 'user_id' => $request->user()->id, + 'content' => $raw, // legacy column (plain text fallback) + 'raw_content' => $raw, + 'rendered_content' => $rendered, + 'is_approved' => true, // auto-approve; extend with moderation as needed + ]); + + // Bust the comments cache for this user's 'all' feed + Cache::forget('comments.latest.all.page1'); + + $comment->load(['user', 'user.profile']); + + return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201); + } + + // ───────────────────────────────────────────────────────────────────────── + // Update + // ───────────────────────────────────────────────────────────────────────── + + public function update(Request $request, int $artworkId, int $commentId): JsonResponse + { + $comment = ArtworkComment::where('artwork_id', $artworkId) + ->findOrFail($commentId); + + Gate::authorize('update', $comment); + + $request->validate([ + 'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH], + ]); + + $raw = $request->input('content'); + $errors = ContentSanitizer::validate($raw); + if ($errors) { + return response()->json(['errors' => ['content' => $errors]], 422); + } + + $rendered = ContentSanitizer::render($raw); + + $comment->update([ + 'content' => $raw, + 'raw_content' => $raw, + 'rendered_content' => $rendered, + ]); + + Cache::forget('comments.latest.all.page1'); + + $comment->load(['user', 'user.profile']); + + return response()->json(['data' => $this->formatComment($comment, $request->user()->id)]); + } + + // ───────────────────────────────────────────────────────────────────────── + // Delete + // ───────────────────────────────────────────────────────────────────────── + + public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse + { + $comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId); + + Gate::authorize('delete', $comment); + + $comment->delete(); + Cache::forget('comments.latest.all.page1'); + + return response()->json(['message' => 'Comment deleted.'], 200); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private function formatComment(ArtworkComment $c, ?int $currentUserId): array + { + $user = $c->user; + $userId = (int) ($c->user_id ?? 0); + $avatarHash = $user?->profile?->avatar_hash ?? null; + + return [ + 'id' => $c->id, + 'raw_content' => $c->raw_content ?? $c->content, + 'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')), + 'created_at' => $c->created_at?->toIso8601String(), + 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, + 'can_edit' => $currentUserId === $userId, + 'can_delete' => $currentUserId === $userId, + 'user' => [ + 'id' => $userId, + 'username' => $user?->username, + 'display' => $user?->username ?? $user?->name ?? 'User', + 'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, + 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), + ], + ]; + } +} diff --git a/app/Http/Controllers/Api/ArtworkInteractionController.php b/app/Http/Controllers/Api/ArtworkInteractionController.php index a7d5bde0..f118a2c8 100644 --- a/app/Http/Controllers/Api/ArtworkInteractionController.php +++ b/app/Http/Controllers/Api/ArtworkInteractionController.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Services\FollowService; +use App\Services\UserStatsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -14,6 +16,8 @@ final class ArtworkInteractionController extends Controller { public function favorite(Request $request, int $artworkId): JsonResponse { + $state = $request->boolean('state', true); + $this->toggleSimple( request: $request, table: 'user_favorites', @@ -25,6 +29,18 @@ final class ArtworkInteractionController extends Controller $this->syncArtworkStats($artworkId); + // Update creator's favorites_received_count + $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id'); + if ($creatorId) { + $svc = app(UserStatsService::class); + if ($state) { + $svc->incrementFavoritesReceived($creatorId); + $svc->setLastActiveAt((int) $request->user()->id); + } else { + $svc->decrementFavoritesReceived($creatorId); + } + } + return response()->json($this->statusPayload((int) $request->user()->id, $artworkId)); } @@ -72,41 +88,25 @@ final class ArtworkInteractionController extends Controller public function follow(Request $request, int $userId): JsonResponse { - if (! Schema::hasTable('friends_list')) { - return response()->json(['message' => 'Follow unavailable'], 422); - } - $actorId = (int) $request->user()->id; + if ($actorId === $userId) { return response()->json(['message' => 'Cannot follow yourself'], 422); } + $svc = app(FollowService::class); $state = $request->boolean('state', true); - $query = DB::table('friends_list') - ->where('user_id', $actorId) - ->where('friend_id', $userId); - if ($state) { - if (! $query->exists()) { - DB::table('friends_list')->insert([ - 'user_id' => $actorId, - 'friend_id' => $userId, - 'date_added' => now(), - ]); - } + $svc->follow($actorId, $userId); } else { - $query->delete(); + $svc->unfollow($actorId, $userId); } - $followersCount = (int) DB::table('friends_list') - ->where('friend_id', $userId) - ->count(); - return response()->json([ - 'ok' => true, - 'is_following' => $state, - 'followers_count' => $followersCount, + 'ok' => true, + 'is_following' => $state, + 'followers_count' => $svc->followersCount($userId), ]); } diff --git a/app/Http/Controllers/Api/FollowController.php b/app/Http/Controllers/Api/FollowController.php new file mode 100644 index 00000000..c66bbb99 --- /dev/null +++ b/app/Http/Controllers/Api/FollowController.php @@ -0,0 +1,137 @@ +resolveUser($username); + $actor = Auth::user(); + + if ($actor->id === $target->id) { + return response()->json(['error' => 'Cannot follow yourself.'], 422); + } + + try { + $this->followService->follow((int) $actor->id, (int) $target->id); + } catch (\InvalidArgumentException $e) { + return response()->json(['error' => $e->getMessage()], 422); + } + + return response()->json([ + 'following' => true, + 'followers_count' => $this->followService->followersCount((int) $target->id), + ]); + } + + // ─── DELETE /api/user/{username}/follow ────────────────────────────────── + + public function unfollow(Request $request, string $username): JsonResponse + { + $target = $this->resolveUser($username); + $actor = Auth::user(); + + $this->followService->unfollow((int) $actor->id, (int) $target->id); + + return response()->json([ + 'following' => false, + 'followers_count' => $this->followService->followersCount((int) $target->id), + ]); + } + + // ─── GET /api/user/{username}/followers ────────────────────────────────── + + public function followers(Request $request, string $username): JsonResponse + { + $target = $this->resolveUser($username); + $perPage = min((int) $request->query('per_page', 24), 100); + + $rows = DB::table('user_followers as uf') + ->join('users as u', 'u.id', '=', 'uf.follower_id') + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') + ->where('uf.user_id', $target->id) + ->whereNull('u.deleted_at') + ->orderByDesc('uf.created_at') + ->select([ + 'u.id', 'u.username', 'u.name', + 'up.avatar_hash', + 'uf.created_at as followed_at', + ]) + ->paginate($perPage) + ->through(fn ($row) => [ + 'id' => $row->id, + 'username' => $row->username, + 'display_name'=> $row->username ?? $row->name, + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), + 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), + 'followed_at' => $row->followed_at, + ]); + + return response()->json($rows); + } + + // ─── GET /api/user/{username}/following ────────────────────────────────── + + public function following(Request $request, string $username): JsonResponse + { + $target = $this->resolveUser($username); + $perPage = min((int) $request->query('per_page', 24), 100); + + $rows = DB::table('user_followers as uf') + ->join('users as u', 'u.id', '=', 'uf.user_id') + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') + ->where('uf.follower_id', $target->id) + ->whereNull('u.deleted_at') + ->orderByDesc('uf.created_at') + ->select([ + 'u.id', 'u.username', 'u.name', + 'up.avatar_hash', + 'uf.created_at as followed_at', + ]) + ->paginate($perPage) + ->through(fn ($row) => [ + 'id' => $row->id, + 'username' => $row->username, + 'display_name'=> $row->username ?? $row->name, + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), + 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), + 'followed_at' => $row->followed_at, + ]); + + return response()->json($rows); + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private function resolveUser(string $username): User + { + $normalized = UsernamePolicy::normalize($username); + + return User::query() + ->whereRaw('LOWER(username) = ?', [$normalized]) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/LatestCommentsApiController.php b/app/Http/Controllers/Api/LatestCommentsApiController.php new file mode 100644 index 00000000..86a26d65 --- /dev/null +++ b/app/Http/Controllers/Api/LatestCommentsApiController.php @@ -0,0 +1,113 @@ +query('type', 'all'); + + // Validate filter type + if (! in_array($type, ['all', 'following', 'mine'], true)) { + $type = 'all'; + } + + // 'mine' and 'following' require auth + if (in_array($type, ['mine', 'following'], true) && ! $request->user()) { + return response()->json(['error' => 'Unauthenticated'], 401); + } + + $query = ArtworkComment::with(['user', 'user.profile', 'artwork']) + ->whereHas('artwork', function ($q) { + $q->public()->published()->whereNull('deleted_at'); + }) + ->orderByDesc('artwork_comments.created_at'); + + switch ($type) { + case 'mine': + $query->where('artwork_comments.user_id', $request->user()->id); + break; + + case 'following': + $followingIds = $request->user() + ->following() + ->pluck('users.id'); + $query->whereIn('artwork_comments.user_id', $followingIds); + break; + + default: + // 'all' — cache the first page only + if ((int) $request->query('page', 1) === 1) { + $cacheKey = 'comments.latest.all.page1'; + $ttl = 120; // 2 minutes + + $paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE)); + } else { + $paginator = $query->paginate(self::PER_PAGE); + } + break; + } + + if (! isset($paginator)) { + $paginator = $query->paginate(self::PER_PAGE); + } + + $items = $paginator->getCollection()->map(function (ArtworkComment $c) { + $art = $c->artwork; + $user = $c->user; + + $present = $art ? ThumbnailPresenter::present($art, 'md') : null; + $thumb = $present ? ($present['url'] ?? null) : null; + + $userId = (int) ($c->user_id ?? 0); + $avatarHash = $user?->profile?->avatar_hash ?? null; + + return [ + 'comment_id' => $c->getKey(), + 'comment_text' => e(strip_tags($c->content ?? '')), + 'created_at' => $c->created_at?->toIso8601String(), + 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, + + 'commenter' => [ + 'id' => $userId, + 'username' => $user?->username ?? null, + 'display' => $user?->username ?? $user?->name ?? 'User', + 'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, + 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), + ], + + 'artwork' => $art ? [ + 'id' => $art->id, + 'title' => $art->title, + 'slug' => $art->slug ?? Str::slug($art->title ?? ''), + 'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')), + 'thumb' => $thumb, + ] : null, + ]; + }); + + return response()->json([ + 'data' => $items, + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'has_more' => $paginator->hasMorePages(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Messaging/AttachmentController.php b/app/Http/Controllers/Api/Messaging/AttachmentController.php new file mode 100644 index 00000000..b6752a21 --- /dev/null +++ b/app/Http/Controllers/Api/Messaging/AttachmentController.php @@ -0,0 +1,41 @@ +with('message:id,conversation_id') + ->findOrFail($id); + + $conversationId = (int) ($attachment->message?->conversation_id ?? 0); + abort_if($conversationId <= 0, 404, 'Attachment not available.'); + + $authorized = \App\Models\ConversationParticipant::query() + ->where('conversation_id', $conversationId) + ->where('user_id', $request->user()->id) + ->whereNull('left_at') + ->exists(); + + abort_unless($authorized, 403, 'You are not allowed to access this attachment.'); + + $diskName = (string) config('messaging.attachments.disk', 'local'); + $disk = Storage::disk($diskName); + + return new StreamedResponse(function () use ($disk, $attachment): void { + echo $disk->get($attachment->storage_path); + }, 200, [ + 'Content-Type' => $attachment->mime, + 'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"', + 'Content-Length' => (string) $attachment->size_bytes, + ]); + } +} diff --git a/app/Http/Controllers/Api/Messaging/ConversationController.php b/app/Http/Controllers/Api/Messaging/ConversationController.php new file mode 100644 index 00000000..310870b5 --- /dev/null +++ b/app/Http/Controllers/Api/Messaging/ConversationController.php @@ -0,0 +1,466 @@ +user(); + $page = max(1, (int) $request->integer('page', 1)); + $cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1); + $cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion); + + $conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) { + return Conversation::query() + ->select('conversations.*') + ->join('conversation_participants as cp_me', function ($join) use ($user) { + $join->on('cp_me.conversation_id', '=', 'conversations.id') + ->where('cp_me.user_id', '=', $user->id) + ->whereNull('cp_me.left_at'); + }) + ->addSelect([ + 'unread_count' => Message::query() + ->selectRaw('count(*)') + ->whereColumn('messages.conversation_id', 'conversations.id') + ->where('messages.sender_id', '!=', $user->id) + ->whereNull('messages.deleted_at') + ->where(function ($query) { + $query->whereNull('cp_me.last_read_at') + ->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at'); + }), + ]) + ->with([ + 'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']), + 'latestMessage.sender:id,username', + ]) + ->orderByDesc('cp_me.is_pinned') + ->orderByDesc('cp_me.pinned_at') + ->orderByDesc('last_message_at') + ->orderByDesc('conversations.id') + ->paginate(20, ['conversations.*'], 'page', $page); + }); + + $conversations->through(function ($conv) use ($user) { + $conv->my_participant = $conv->allParticipants + ->firstWhere('user_id', $user->id); + return $conv; + }); + + return response()->json($conversations); + } + + // ── GET /api/messages/conversation/{id} ───────────────────────────────── + + public function show(Request $request, int $id): JsonResponse + { + $conv = $this->findAuthorized($request, $id); + + $conv->load([ + 'allParticipants.user:id,username', + 'creator:id,username', + ]); + + return response()->json($conv); + } + + // ── POST /api/messages/conversation ───────────────────────────────────── + + public function store(Request $request): JsonResponse + { + $user = $request->user(); + + $data = $request->validate([ + 'type' => 'required|in:direct,group', + 'recipient_id' => 'required_if:type,direct|integer|exists:users,id', + 'participant_ids' => 'required_if:type,group|array|min:2', + 'participant_ids.*'=> 'integer|exists:users,id', + 'title' => 'required_if:type,group|nullable|string|max:120', + 'body' => 'required|string|max:5000', + ]); + + if ($data['type'] === 'direct') { + return $this->createDirect($request, $user, $data); + } + + return $this->createGroup($request, $user, $data); + } + + // ── POST /api/messages/{conversation_id}/read ──────────────────────────── + + public function markRead(Request $request, int $id): JsonResponse + { + $participant = $this->participantRecord($request, $id); + $participant->update(['last_read_at' => now()]); + $this->touchConversationCachesForUsers([$request->user()->id]); + + return response()->json(['ok' => true]); + } + + // ── POST /api/messages/{conversation_id}/archive ───────────────────────── + + public function archive(Request $request, int $id): JsonResponse + { + $participant = $this->participantRecord($request, $id); + $participant->update(['is_archived' => ! $participant->is_archived]); + $this->touchConversationCachesForUsers([$request->user()->id]); + + return response()->json(['is_archived' => $participant->is_archived]); + } + + // ── POST /api/messages/{conversation_id}/mute ──────────────────────────── + + public function mute(Request $request, int $id): JsonResponse + { + $participant = $this->participantRecord($request, $id); + $participant->update(['is_muted' => ! $participant->is_muted]); + $this->touchConversationCachesForUsers([$request->user()->id]); + + return response()->json(['is_muted' => $participant->is_muted]); + } + + public function pin(Request $request, int $id): JsonResponse + { + $participant = $this->participantRecord($request, $id); + $participant->update(['is_pinned' => true, 'pinned_at' => now()]); + $this->touchConversationCachesForUsers([$request->user()->id]); + + return response()->json(['is_pinned' => true]); + } + + public function unpin(Request $request, int $id): JsonResponse + { + $participant = $this->participantRecord($request, $id); + $participant->update(['is_pinned' => false, 'pinned_at' => null]); + $this->touchConversationCachesForUsers([$request->user()->id]); + + return response()->json(['is_pinned' => false]); + } + + // ── DELETE /api/messages/{conversation_id}/leave ───────────────────────── + + public function leave(Request $request, int $id): JsonResponse + { + $conv = $this->findAuthorized($request, $id); + $participant = $this->participantRecord($request, $id); + $participantUserIds = ConversationParticipant::where('conversation_id', $id) + ->whereNull('left_at') + ->pluck('user_id') + ->all(); + + if ($conv->isGroup()) { + // Last admin protection + $adminCount = ConversationParticipant::where('conversation_id', $id) + ->where('role', 'admin') + ->whereNull('left_at') + ->count(); + + if ($adminCount === 1 && $participant->role === 'admin') { + $otherMember = ConversationParticipant::where('conversation_id', $id) + ->where('user_id', '!=', $request->user()->id) + ->whereNull('left_at') + ->first(); + + if ($otherMember) { + $otherMember->update(['role' => 'admin']); + } + } + } + + $participant->update(['left_at' => now()]); + $this->touchConversationCachesForUsers($participantUserIds); + + return response()->json(['ok' => true]); + } + + // ── POST /api/messages/{conversation_id}/add-user ──────────────────────── + + public function addUser(Request $request, int $id): JsonResponse + { + $conv = $this->findAuthorized($request, $id); + $this->requireAdmin($request, $id); + $participantUserIds = ConversationParticipant::where('conversation_id', $id) + ->whereNull('left_at') + ->pluck('user_id') + ->all(); + + $data = $request->validate([ + 'user_id' => 'required|integer|exists:users,id', + ]); + + $existing = ConversationParticipant::where('conversation_id', $id) + ->where('user_id', $data['user_id']) + ->first(); + + if ($existing) { + if ($existing->left_at) { + $existing->update(['left_at' => null, 'joined_at' => now()]); + } + } else { + ConversationParticipant::create([ + 'conversation_id' => $id, + 'user_id' => $data['user_id'], + 'role' => 'member', + 'joined_at' => now(), + ]); + } + + $participantUserIds[] = (int) $data['user_id']; + $this->touchConversationCachesForUsers($participantUserIds); + + return response()->json(['ok' => true]); + } + + // ── DELETE /api/messages/{conversation_id}/remove-user ─────────────────── + + public function removeUser(Request $request, int $id): JsonResponse + { + $this->requireAdmin($request, $id); + + $data = $request->validate([ + 'user_id' => 'required|integer', + ]); + + // Cannot remove the conversation creator + $conv = Conversation::findOrFail($id); + abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.'); + + $targetParticipant = ConversationParticipant::where('conversation_id', $id) + ->where('user_id', $data['user_id']) + ->whereNull('left_at') + ->first(); + + if ($targetParticipant && $targetParticipant->role === 'admin') { + $adminCount = ConversationParticipant::where('conversation_id', $id) + ->where('role', 'admin') + ->whereNull('left_at') + ->count(); + + abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.'); + } + + $participantUserIds = ConversationParticipant::where('conversation_id', $id) + ->whereNull('left_at') + ->pluck('user_id') + ->all(); + + ConversationParticipant::where('conversation_id', $id) + ->where('user_id', $data['user_id']) + ->whereNull('left_at') + ->update(['left_at' => now()]); + + $this->touchConversationCachesForUsers($participantUserIds); + + return response()->json(['ok' => true]); + } + + // ── POST /api/messages/{conversation_id}/rename ────────────────────────── + + public function rename(Request $request, int $id): JsonResponse + { + $conv = $this->findAuthorized($request, $id); + abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.'); + $this->requireAdmin($request, $id); + + $data = $request->validate(['title' => 'required|string|max:120']); + $conv->update(['title' => $data['title']]); + $participantUserIds = ConversationParticipant::where('conversation_id', $id) + ->whereNull('left_at') + ->pluck('user_id') + ->all(); + $this->touchConversationCachesForUsers($participantUserIds); + + return response()->json(['title' => $conv->title]); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private function createDirect(Request $request, User $user, array $data): JsonResponse + { + $recipient = User::findOrFail($data['recipient_id']); + + abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.'); + + if (! $recipient->allowsMessagesFrom($user)) { + abort(403, 'This user does not accept messages from you.'); + } + + $this->assertNotBlockedBetween($user, $recipient); + + // Reuse existing conversation if one exists + $conv = Conversation::findDirect($user->id, $recipient->id); + + if (! $conv) { + $conv = DB::transaction(function () use ($user, $recipient) { + $conv = Conversation::create([ + 'type' => 'direct', + 'created_by' => $user->id, + ]); + + ConversationParticipant::insert([ + ['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()], + ['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()], + ]); + + return $conv; + }); + } + + // Insert first / next message + $message = $conv->messages()->create([ + 'sender_id' => $user->id, + 'body' => $data['body'], + ]); + + $conv->update(['last_message_at' => $message->created_at]); + app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user); + $this->touchConversationCachesForUsers([$user->id, $recipient->id]); + + return response()->json($conv->load('allParticipants.user:id,username'), 201); + } + + private function createGroup(Request $request, User $user, array $data): JsonResponse + { + $participantIds = array_unique(array_merge([$user->id], $data['participant_ids'])); + + $conv = DB::transaction(function () use ($user, $data, $participantIds) { + $conv = Conversation::create([ + 'type' => 'group', + 'title' => $data['title'], + 'created_by' => $user->id, + ]); + + $rows = array_map(fn ($uid) => [ + 'conversation_id' => $conv->id, + 'user_id' => $uid, + 'role' => $uid === $user->id ? 'admin' : 'member', + 'joined_at' => now(), + ], $participantIds); + + ConversationParticipant::insert($rows); + + $message = $conv->messages()->create([ + 'sender_id' => $user->id, + 'body' => $data['body'], + ]); + + $conv->update(['last_message_at' => $message->created_at]); + + return [$conv, $message]; + }); + + [$conversation, $message] = $conv; + app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user); + $this->touchConversationCachesForUsers($participantIds); + + return response()->json($conversation->load('allParticipants.user:id,username'), 201); + } + + private function findAuthorized(Request $request, int $id): Conversation + { + $conv = Conversation::findOrFail($id); + $this->assertParticipant($request, $id); + return $conv; + } + + private function participantRecord(Request $request, int $conversationId): ConversationParticipant + { + return ConversationParticipant::where('conversation_id', $conversationId) + ->where('user_id', $request->user()->id) + ->whereNull('left_at') + ->firstOrFail(); + } + + private function assertParticipant(Request $request, int $id): void + { + abort_unless( + ConversationParticipant::where('conversation_id', $id) + ->where('user_id', $request->user()->id) + ->whereNull('left_at') + ->exists(), + 403, + 'You are not a participant of this conversation.' + ); + } + + private function requireAdmin(Request $request, int $id): void + { + abort_unless( + ConversationParticipant::where('conversation_id', $id) + ->where('user_id', $request->user()->id) + ->where('role', 'admin') + ->whereNull('left_at') + ->exists(), + 403, + 'Only admins can perform this action.' + ); + } + + private function touchConversationCachesForUsers(array $userIds): void + { + foreach (array_unique($userIds) as $userId) { + if (! $userId) { + continue; + } + + $versionKey = $this->cacheVersionKey((int) $userId); + Cache::add($versionKey, 1, now()->addDay()); + Cache::increment($versionKey); + } + } + + private function cacheVersionKey(int $userId): string + { + return "messages:conversations:version:{$userId}"; + } + + private function conversationListCacheKey(int $userId, int $page, int $version): string + { + return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}"; + } + + private function assertNotBlockedBetween(User $sender, User $recipient): void + { + if (! Schema::hasTable('user_blocks')) { + return; + } + + $blocked = false; + + if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) { + $blocked = DB::table('user_blocks') + ->where(function ($q) use ($sender, $recipient) { + $q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id); + }) + ->orWhere(function ($q) use ($sender, $recipient) { + $q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id); + }) + ->exists(); + } elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) { + $blocked = DB::table('user_blocks') + ->where(function ($q) use ($sender, $recipient) { + $q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id); + }) + ->orWhere(function ($q) use ($sender, $recipient) { + $q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id); + }) + ->exists(); + } + + abort_if($blocked, 403, 'Messaging is not available between these users.'); + } +} diff --git a/app/Http/Controllers/Api/Messaging/MessageController.php b/app/Http/Controllers/Api/Messaging/MessageController.php new file mode 100644 index 00000000..b9434256 --- /dev/null +++ b/app/Http/Controllers/Api/Messaging/MessageController.php @@ -0,0 +1,351 @@ +assertParticipant($request, $conversationId); + $cursor = $request->integer('cursor'); + + $query = Message::withTrashed() + ->where('conversation_id', $conversationId) + ->with(['sender:id,username', 'reactions', 'attachments']) + ->orderByDesc('created_at') + ->orderByDesc('id'); + + if ($cursor) { + $query->where('id', '<', $cursor); + } + + $chunk = $query->limit(self::PAGE_SIZE + 1)->get(); + $hasMore = $chunk->count() > self::PAGE_SIZE; + $messages = $chunk->take(self::PAGE_SIZE)->reverse()->values(); + $nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null; + + return response()->json([ + 'data' => $messages, + 'next_cursor' => $nextCursor, + ]); + } + + // ── POST /api/messages/{conversation_id} ───────────────────────────────── + + public function store(Request $request, int $conversationId): JsonResponse + { + $this->assertParticipant($request, $conversationId); + + $data = $request->validate([ + 'body' => 'nullable|string|max:5000', + 'attachments' => 'sometimes|array|max:5', + 'attachments.*' => 'file|max:25600', + ]); + + $body = trim((string) ($data['body'] ?? '')); + $files = $request->file('attachments', []); + abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.'); + + $message = Message::create([ + 'conversation_id' => $conversationId, + 'sender_id' => $request->user()->id, + 'body' => $body, + ]); + + foreach ($files as $file) { + if ($file instanceof UploadedFile) { + $this->storeAttachment($file, $message, (int) $request->user()->id); + } + } + + Conversation::where('id', $conversationId) + ->update(['last_message_at' => $message->created_at]); + + $conversation = Conversation::findOrFail($conversationId); + app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user()); + app(MessageSearchIndexer::class)->indexMessage($message); + event(new MessageSent($conversationId, $message->id, $request->user()->id)); + + $participantUserIds = ConversationParticipant::where('conversation_id', $conversationId) + ->whereNull('left_at') + ->pluck('user_id') + ->all(); + $this->touchConversationCachesForUsers($participantUserIds); + + $message->load(['sender:id,username', 'attachments']); + + return response()->json($message, 201); + } + + // ── POST /api/messages/{conversation_id}/react ─────────────────────────── + + public function react(Request $request, int $conversationId, int $messageId): JsonResponse + { + $this->assertParticipant($request, $conversationId); + + $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->assertAllowedReaction($data['reaction']); + + $existing = MessageReaction::where([ + 'message_id' => $messageId, + 'user_id' => $request->user()->id, + 'reaction' => $data['reaction'], + ])->first(); + + if ($existing) { + $existing->delete(); + } else { + MessageReaction::create([ + 'message_id' => $messageId, + 'user_id' => $request->user()->id, + 'reaction' => $data['reaction'], + ]); + } + + return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); + } + + // ── DELETE /api/messages/{conversation_id}/react ───────────────────────── + + public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse + { + $this->assertParticipant($request, $conversationId); + + $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->assertAllowedReaction($data['reaction']); + + MessageReaction::where([ + 'message_id' => $messageId, + 'user_id' => $request->user()->id, + 'reaction' => $data['reaction'], + ])->delete(); + + return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); + } + + public function reactByMessage(Request $request, int $messageId): JsonResponse + { + $message = Message::query()->findOrFail($messageId); + $this->assertParticipant($request, (int) $message->conversation_id); + + $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->assertAllowedReaction($data['reaction']); + + $existing = MessageReaction::where([ + 'message_id' => $messageId, + 'user_id' => $request->user()->id, + 'reaction' => $data['reaction'], + ])->first(); + + if ($existing) { + $existing->delete(); + } else { + MessageReaction::create([ + 'message_id' => $messageId, + 'user_id' => $request->user()->id, + 'reaction' => $data['reaction'], + ]); + } + + return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); + } + + public function unreactByMessage(Request $request, int $messageId): JsonResponse + { + $message = Message::query()->findOrFail($messageId); + $this->assertParticipant($request, (int) $message->conversation_id); + + $data = $request->validate(['reaction' => 'required|string|max:32']); + $this->assertAllowedReaction($data['reaction']); + + MessageReaction::where([ + 'message_id' => $messageId, + 'user_id' => $request->user()->id, + 'reaction' => $data['reaction'], + ])->delete(); + + return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); + } + + // ── PATCH /api/messages/message/{messageId} ─────────────────────────────── + + public function update(Request $request, int $messageId): JsonResponse + { + $message = Message::findOrFail($messageId); + + abort_unless( + $message->sender_id === $request->user()->id, + 403, + 'You may only edit your own messages.' + ); + + abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.'); + + $data = $request->validate(['body' => 'required|string|max:5000']); + + $message->update([ + 'body' => $data['body'], + 'edited_at' => now(), + ]); + app(MessageSearchIndexer::class)->updateMessage($message); + + $participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) + ->whereNull('left_at') + ->pluck('user_id') + ->all(); + $this->touchConversationCachesForUsers($participantUserIds); + + return response()->json($message->fresh()); + } + + // ── DELETE /api/messages/message/{messageId} ────────────────────────────── + + public function destroy(Request $request, int $messageId): JsonResponse + { + $message = Message::findOrFail($messageId); + + abort_unless( + $message->sender_id === $request->user()->id || $request->user()->isAdmin(), + 403, + 'You may only delete your own messages.' + ); + + $participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) + ->whereNull('left_at') + ->pluck('user_id') + ->all(); + app(MessageSearchIndexer::class)->deleteMessage($message); + $message->delete(); + $this->touchConversationCachesForUsers($participantUserIds); + + return response()->json(['ok' => true]); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private function assertParticipant(Request $request, int $conversationId): void + { + abort_unless( + ConversationParticipant::where('conversation_id', $conversationId) + ->where('user_id', $request->user()->id) + ->whereNull('left_at') + ->exists(), + 403, + 'You are not a participant of this conversation.' + ); + } + + private function touchConversationCachesForUsers(array $userIds): void + { + foreach (array_unique($userIds) as $userId) { + if (! $userId) { + continue; + } + + $versionKey = "messages:conversations:version:{$userId}"; + Cache::add($versionKey, 1, now()->addDay()); + Cache::increment($versionKey); + } + } + + private function assertAllowedReaction(string $reaction): void + { + $allowed = (array) config('messaging.reactions.allowed', []); + abort_unless(in_array($reaction, $allowed, true), 422, 'Reaction is not allowed.'); + } + + private function reactionSummary(int $messageId, int $userId): array + { + $rows = MessageReaction::query() + ->selectRaw('reaction, count(*) as aggregate_count') + ->where('message_id', $messageId) + ->groupBy('reaction') + ->get(); + + $summary = []; + foreach ($rows as $row) { + $summary[(string) $row->reaction] = (int) $row->aggregate_count; + } + + $mine = MessageReaction::query() + ->where('message_id', $messageId) + ->where('user_id', $userId) + ->pluck('reaction') + ->values() + ->all(); + + $summary['me'] = $mine; + + return $summary; + } + + private function storeAttachment(UploadedFile $file, Message $message, int $userId): void + { + $mime = (string) $file->getMimeType(); + $finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname()); + $detectedMime = $finfoMime !== '' ? $finfoMime : $mime; + + $allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []); + $allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []); + + $type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file'; + $allowed = $type === 'image' ? $allowedImage : $allowedFile; + + abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.'); + + $maxBytes = $type === 'image' + ? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024) + : ((int) config('messaging.attachments.max_file_kb', 25600) * 1024); + + abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.'); + + $year = now()->format('Y'); + $month = now()->format('m'); + $ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin'); + $path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}"; + + $diskName = (string) config('messaging.attachments.disk', 'local'); + Storage::disk($diskName)->put($path, file_get_contents($file->getPathname())); + + $width = null; + $height = null; + if ($type === 'image') { + $dimensions = @getimagesize($file->getPathname()); + $width = isset($dimensions[0]) ? (int) $dimensions[0] : null; + $height = isset($dimensions[1]) ? (int) $dimensions[1] : null; + } + + MessageAttachment::query()->create([ + 'message_id' => $message->id, + 'user_id' => $userId, + 'type' => $type, + 'mime' => $detectedMime, + 'size_bytes' => (int) $file->getSize(), + 'width' => $width, + 'height' => $height, + 'sha256' => hash_file('sha256', $file->getPathname()), + 'original_name' => substr((string) $file->getClientOriginalName(), 0, 255), + 'storage_path' => $path, + 'created_at' => now(), + ]); + } +} diff --git a/app/Http/Controllers/Api/Messaging/MessageSearchController.php b/app/Http/Controllers/Api/Messaging/MessageSearchController.php new file mode 100644 index 00000000..f6e0e3f7 --- /dev/null +++ b/app/Http/Controllers/Api/Messaging/MessageSearchController.php @@ -0,0 +1,139 @@ +user(); + $data = $request->validate([ + 'q' => 'required|string|min:1|max:200', + 'conversation_id' => 'nullable|integer|exists:conversations,id', + 'cursor' => 'nullable|integer|min:0', + ]); + + $allowedConversationIds = ConversationParticipant::query() + ->where('user_id', $user->id) + ->whereNull('left_at') + ->pluck('conversation_id') + ->map(fn ($id) => (int) $id) + ->all(); + + $conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null; + if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) { + abort(403, 'You are not a participant of this conversation.'); + } + + if (empty($allowedConversationIds)) { + return response()->json(['data' => [], 'next_cursor' => null]); + } + + $limit = max(1, (int) config('messaging.search.page_size', 20)); + $offset = max(0, (int) ($data['cursor'] ?? 0)); + + $hits = collect(); + $estimated = 0; + + try { + $client = new Client( + config('scout.meilisearch.host'), + config('scout.meilisearch.key') + ); + + $prefix = (string) config('scout.prefix', ''); + $indexName = $prefix . (string) config('messaging.search.index', 'messages'); + + $conversationFilter = $conversationId !== null + ? "conversation_id = {$conversationId}" + : 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']'; + + $result = $client + ->index($indexName) + ->search((string) $data['q'], [ + 'limit' => $limit, + 'offset' => $offset, + 'sort' => ['created_at:desc'], + 'filter' => $conversationFilter, + ]); + + $hits = collect($result->getHits() ?? []); + $estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count()); + } catch (\Throwable) { + $query = Message::query() + ->select('id') + ->whereNull('deleted_at') + ->whereIn('conversation_id', $allowedConversationIds) + ->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId)) + ->where('body', 'like', '%' . (string) $data['q'] . '%') + ->orderByDesc('created_at') + ->orderByDesc('id'); + + $estimated = (clone $query)->count(); + $hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]); + } + $messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all(); + + $messages = Message::query() + ->whereIn('id', $messageIds) + ->whereIn('conversation_id', $allowedConversationIds) + ->whereNull('deleted_at') + ->with(['sender:id,username', 'attachments']) + ->get() + ->keyBy('id'); + + $ordered = $hits + ->map(function (array $hit) use ($messages) { + $message = $messages->get((int) ($hit['id'] ?? 0)); + if (! $message) { + return null; + } + + return [ + 'id' => $message->id, + 'conversation_id' => $message->conversation_id, + 'sender_id' => $message->sender_id, + 'sender' => $message->sender, + 'body' => $message->body, + 'created_at' => optional($message->created_at)?->toISOString(), + 'has_attachments' => $message->attachments->isNotEmpty(), + ]; + }) + ->filter() + ->values(); + + $nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null; + + return response()->json([ + 'data' => $ordered, + 'next_cursor' => $nextCursor, + ]); + } + + public function rebuild(Request $request): JsonResponse + { + abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.'); + + $conversationId = $request->integer('conversation_id'); + if ($conversationId > 0) { + $this->indexer->rebuildConversation($conversationId); + return response()->json(['queued' => true, 'scope' => 'conversation']); + } + + $this->indexer->rebuildAll(); + + return response()->json(['queued' => true, 'scope' => 'all']); + } +} diff --git a/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php b/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php new file mode 100644 index 00000000..4cba0b4c --- /dev/null +++ b/app/Http/Controllers/Api/Messaging/MessagingSettingsController.php @@ -0,0 +1,36 @@ +json([ + 'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone', + ]); + } + + public function update(Request $request): JsonResponse + { + $data = $request->validate([ + 'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody', + ]); + + $request->user()->update($data); + + return response()->json([ + 'allow_messages_from' => $request->user()->allow_messages_from, + ]); + } +} diff --git a/app/Http/Controllers/Api/Messaging/TypingController.php b/app/Http/Controllers/Api/Messaging/TypingController.php new file mode 100644 index 00000000..45a0e045 --- /dev/null +++ b/app/Http/Controllers/Api/Messaging/TypingController.php @@ -0,0 +1,96 @@ +assertParticipant($request, $conversationId); + + $ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8)); + $this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl)); + + if ((bool) config('messaging.realtime', false)) { + event(new TypingStarted($conversationId, (int) $request->user()->id)); + } + + return response()->json(['ok' => true]); + } + + public function stop(Request $request, int $conversationId): JsonResponse + { + $this->assertParticipant($request, $conversationId); + $this->store()->forget($this->key($conversationId, (int) $request->user()->id)); + + if ((bool) config('messaging.realtime', false)) { + event(new TypingStopped($conversationId, (int) $request->user()->id)); + } + + return response()->json(['ok' => true]); + } + + public function index(Request $request, int $conversationId): JsonResponse + { + $this->assertParticipant($request, $conversationId); + $userId = (int) $request->user()->id; + + $participants = ConversationParticipant::query() + ->where('conversation_id', $conversationId) + ->whereNull('left_at') + ->where('user_id', '!=', $userId) + ->with('user:id,username') + ->get(); + + $typing = $participants + ->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id))) + ->map(fn ($p) => [ + 'user_id' => (int) $p->user_id, + 'username' => (string) ($p->user->username ?? ''), + ]) + ->values(); + + return response()->json(['typing' => $typing]); + } + + private function assertParticipant(Request $request, int $conversationId): void + { + abort_unless( + ConversationParticipant::query() + ->where('conversation_id', $conversationId) + ->where('user_id', $request->user()->id) + ->whereNull('left_at') + ->exists(), + 403, + 'You are not a participant of this conversation.' + ); + } + + private function key(int $conversationId, int $userId): string + { + return "typing:{$conversationId}:{$userId}"; + } + + private function store(): Repository + { + $store = (string) config('messaging.typing.cache_store', 'redis'); + if ($store === 'redis' && ! class_exists('Redis')) { + return Cache::store(); + } + + try { + return Cache::store($store); + } catch (\Throwable) { + return Cache::store(); + } + } +} diff --git a/app/Http/Controllers/Api/ReactionController.php b/app/Http/Controllers/Api/ReactionController.php new file mode 100644 index 00000000..b962699f --- /dev/null +++ b/app/Http/Controllers/Api/ReactionController.php @@ -0,0 +1,192 @@ +listReactions('artwork', $artworkId, $request->user()?->id); + } + + public function toggleArtworkReaction(Request $request, int $artworkId): JsonResponse + { + $this->validateExists('artworks', $artworkId); + $slug = $this->validateReactionSlug($request); + + return $this->toggle( + model: new ArtworkReaction(), + where: ['artwork_id' => $artworkId, 'user_id' => $request->user()->id, 'reaction' => $slug], + countWhere: ['artwork_id' => $artworkId], + entityId: $artworkId, + entityType: 'artwork', + userId: $request->user()->id, + slug: $slug, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Comment reactions + // ───────────────────────────────────────────────────────────────────────── + + public function commentReactions(Request $request, int $commentId): JsonResponse + { + return $this->listReactions('comment', $commentId, $request->user()?->id); + } + + public function toggleCommentReaction(Request $request, int $commentId): JsonResponse + { + // Make sure comment exists and belongs to a public artwork + $comment = ArtworkComment::with('artwork') + ->where('id', $commentId) + ->whereHas('artwork', fn ($q) => $q->public()->published()) + ->firstOrFail(); + + $slug = $this->validateReactionSlug($request); + + return $this->toggle( + model: new CommentReaction(), + where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug], + countWhere: ['comment_id' => $commentId], + entityId: $commentId, + entityType: 'comment', + userId: $request->user()->id, + slug: $slug, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Shared internals + // ───────────────────────────────────────────────────────────────────────── + + private function toggle( + \Illuminate\Database\Eloquent\Model $model, + array $where, + array $countWhere, + int $entityId, + string $entityType, + int $userId, + string $slug, + ): JsonResponse { + $table = $model->getTable(); + $existing = DB::table($table)->where($where)->first(); + + if ($existing) { + // Toggle off + DB::table($table)->where($where)->delete(); + $active = false; + } else { + // Toggle on + DB::table($table)->insertOrIgnore(array_merge($where, [ + 'created_at' => now(), + ])); + $active = true; + } + + // Return fresh totals per reaction type + $totals = $this->getTotals($table, $countWhere, $userId); + + return response()->json([ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'reaction' => $slug, + 'active' => $active, + 'totals' => $totals, + ]); + } + + private function listReactions(string $entityType, int $entityId, ?int $userId): JsonResponse + { + if ($entityType === 'artwork') { + $table = 'artwork_reactions'; + $where = ['artwork_id' => $entityId]; + } else { + $table = 'comment_reactions'; + $where = ['comment_id' => $entityId]; + } + + $totals = $this->getTotals($table, $where, $userId); + + return response()->json([ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'totals' => $totals, + ]); + } + + /** + * Return per-slug totals and whether the current user has each reaction. + */ + private function getTotals(string $table, array $where, ?int $userId): array + { + $rows = DB::table($table) + ->where($where) + ->selectRaw('reaction, COUNT(*) as total') + ->groupBy('reaction') + ->get() + ->keyBy('reaction'); + + $totals = []; + foreach (ReactionType::cases() as $type) { + $slug = $type->value; + $count = (int) ($rows[$slug]->total ?? 0); + + // Check if current user has this reaction + $mine = false; + if ($userId && $count > 0) { + $mine = DB::table($table) + ->where($where) + ->where('reaction', $slug) + ->where('user_id', $userId) + ->exists(); + } + + $totals[$slug] = [ + 'emoji' => $type->emoji(), + 'label' => $type->label(), + 'count' => $count, + 'mine' => $mine, + ]; + } + + return $totals; + } + + private function validateReactionSlug(Request $request): string + { + $request->validate([ + 'reaction' => ['required', 'string', 'in:' . implode(',', ReactionType::values())], + ]); + + return $request->input('reaction'); + } + + private function validateExists(string $table, int $id): void + { + if (! DB::table($table)->where('id', $id)->exists()) { + throw new ModelNotFoundException("No [{$table}] record found with id [{$id}]."); + } + } +} diff --git a/app/Http/Controllers/Api/ReportController.php b/app/Http/Controllers/Api/ReportController.php new file mode 100644 index 00000000..0d4a711d --- /dev/null +++ b/app/Http/Controllers/Api/ReportController.php @@ -0,0 +1,63 @@ +user(); + + $data = $request->validate([ + 'target_type' => 'required|in:message,conversation,user', + 'target_id' => 'required|integer|min:1', + 'reason' => 'required|string|max:120', + 'details' => 'nullable|string|max:4000', + ]); + + $targetType = $data['target_type']; + $targetId = (int) $data['target_id']; + + if ($targetType === 'message') { + $message = Message::query()->findOrFail($targetId); + $allowed = ConversationParticipant::query() + ->where('conversation_id', $message->conversation_id) + ->where('user_id', $user->id) + ->whereNull('left_at') + ->exists(); + abort_unless($allowed, 403, 'You are not allowed to report this message.'); + } + + if ($targetType === 'conversation') { + $allowed = ConversationParticipant::query() + ->where('conversation_id', $targetId) + ->where('user_id', $user->id) + ->whereNull('left_at') + ->exists(); + abort_unless($allowed, 403, 'You are not allowed to report this conversation.'); + } + + if ($targetType === 'user') { + User::query()->findOrFail($targetId); + } + + $report = Report::query()->create([ + 'reporter_id' => $user->id, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'reason' => $data['reason'], + 'details' => $data['details'] ?? null, + 'status' => 'open', + ]); + + return response()->json(['id' => $report->id, 'status' => $report->status], 201); + } +} diff --git a/app/Http/Controllers/Api/Search/UserSearchController.php b/app/Http/Controllers/Api/Search/UserSearchController.php new file mode 100644 index 00000000..ce0e1a97 --- /dev/null +++ b/app/Http/Controllers/Api/Search/UserSearchController.php @@ -0,0 +1,59 @@ +query('q', '')); + $q = ltrim($raw, '@'); + + if (strlen($q) < 2) { + return response()->json(['data' => []]); + } + + $perPage = min((int) $request->query('per_page', 4), 8); + + $users = User::query() + ->where('is_active', 1) + ->whereNull('deleted_at') + ->where(function ($qb) use ($q) { + $qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']); + }) + ->with(['profile', 'statistics']) + ->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first + ->orderBy('username') + ->limit($perPage) + ->get(['id', 'username']); + + $data = $users->map(function (User $user) { + $username = strtolower((string) ($user->username ?? '')); + $avatarHash = $user->profile?->avatar_hash; + $uploadsCount = (int) ($user->statistics?->uploads_count ?? 0); + + return [ + 'id' => $user->id, + 'type' => 'user', + 'username' => $username, + 'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64), + 'uploads_count' => $uploadsCount, + 'profile_url' => '/@' . $username, + ]; + }); + + return response()->json(['data' => $data]); + } +} diff --git a/app/Http/Controllers/Community/LatestCommentsController.php b/app/Http/Controllers/Community/LatestCommentsController.php index d5a20a88..03790de4 100644 --- a/app/Http/Controllers/Community/LatestCommentsController.php +++ b/app/Http/Controllers/Community/LatestCommentsController.php @@ -5,51 +5,75 @@ namespace App\Http\Controllers\Community; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use App\Models\ArtworkComment; -use Illuminate\Support\Facades\DB; +use App\Support\AvatarUrl; +use App\Services\ThumbnailPresenter; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Carbon\Carbon; class LatestCommentsController extends Controller { + private const PER_PAGE = 20; + public function index(Request $request) { - $hits = 20; + $page_title = 'Latest Comments'; - $query = ArtworkComment::with(['user', 'artwork']) - ->whereHas('artwork', function ($q) { - $q->public()->published()->whereNull('deleted_at'); - }) - ->orderByDesc('created_at'); + // Build initial (first-page, type=all) data for React SSR props + $initialData = Cache::remember('comments.latest.all.page1', 120, function () { + return ArtworkComment::with(['user', 'user.profile', 'artwork']) + ->whereHas('artwork', function ($q) { + $q->public()->published()->whereNull('deleted_at'); + }) + ->orderByDesc('artwork_comments.created_at') + ->paginate(self::PER_PAGE); + }); - $comments = $query->paginate($hits)->withQueryString(); - - $comments->getCollection()->transform(function (ArtworkComment $c) { - $art = $c->artwork; + $items = $initialData->getCollection()->map(function (ArtworkComment $c) { + $art = $c->artwork; $user = $c->user; - $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; - $thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg'; + $present = $art ? ThumbnailPresenter::present($art, 'md') : null; + $thumb = $present ? ($present['url'] ?? null) : null; + $userId = (int) ($c->user_id ?? 0); + $avatarHash = $user?->profile?->avatar_hash ?? null; - return (object) [ - 'comment_id' => $c->getKey(), - 'comment_description' => $c->content, - 'commenter_id' => $c->user_id, - 'commenter_username' => $user?->username ?? null, - 'country' => $user->country ?? null, - 'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null, - 'uname' => $user->username ?? $user->name ?? 'User', - 'signature' => $user->signature ?? null, - 'user_type' => $user->role ?? null, - 'id' => $art->id ?? null, - 'name' => $art->title ?? null, - 'picture' => $art->file_name ?? null, - 'thumb' => $thumb, - 'artwork_slug' => $art->slug ?? Str::slug($art->title ?? ''), - 'datetime' => $c->created_at?->toDateTimeString() ?? now()->toDateTimeString(), + return [ + 'comment_id' => $c->getKey(), + 'comment_text' => e(strip_tags($c->content ?? '')), + 'created_at' => $c->created_at?->toIso8601String(), + 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, + + 'commenter' => [ + 'id' => $userId, + 'username' => $user?->username ?? null, + 'display' => $user?->username ?? $user?->name ?? 'User', + 'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, + 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), + ], + + 'artwork' => $art ? [ + 'id' => $art->id, + 'title' => $art->title, + 'slug' => $art->slug ?? Str::slug($art->title ?? ''), + 'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')), + 'thumb' => $thumb, + ] : null, ]; }); - $page_title = 'Latest Comments'; + $props = [ + 'initialComments' => $items->values()->all(), + 'initialMeta' => [ + 'current_page' => $initialData->currentPage(), + 'last_page' => $initialData->lastPage(), + 'per_page' => $initialData->perPage(), + 'total' => $initialData->total(), + 'has_more' => $initialData->hasMorePages(), + ], + 'isAuthenticated' => (bool) auth()->user(), + ]; - return view('web.comments.latest', compact('page_title', 'comments')); + return view('web.comments.latest', compact('page_title', 'props')); } } diff --git a/app/Http/Controllers/Dashboard/DashboardAwardsController.php b/app/Http/Controllers/Dashboard/DashboardAwardsController.php new file mode 100644 index 00000000..d9a2f94d --- /dev/null +++ b/app/Http/Controllers/Dashboard/DashboardAwardsController.php @@ -0,0 +1,33 @@ +user(); + + $artworks = Artwork::query() + ->where('user_id', (int) $user->id) + ->whereHas('awards') + ->with(['awardStat', 'stats', 'categories.contentType']) + ->orderByDesc( + \App\Models\ArtworkAwardStat::select('score_total') + ->whereColumn('artwork_id', 'artworks.id') + ->limit(1) + ) + ->paginate(24) + ->withQueryString(); + + return view('dashboard.awards', [ + 'artworks' => $artworks, + 'page_title' => 'My Awards – SkinBase', + ]); + } +} diff --git a/app/Http/Controllers/Dashboard/FavoriteController.php b/app/Http/Controllers/Dashboard/FavoriteController.php index 904cc9b2..cd1e6911 100644 --- a/app/Http/Controllers/Dashboard/FavoriteController.php +++ b/app/Http/Controllers/Dashboard/FavoriteController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Dashboard; use App\Http\Controllers\Controller; use App\Models\Artwork; +use App\Services\UserStatsService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\DB; @@ -17,23 +18,10 @@ class FavoriteController extends Controller $user = $request->user(); $perPage = 20; - $favTable = DB::getSchemaBuilder()->hasTable('user_favorites') ? 'user_favorites' : (DB::getSchemaBuilder()->hasTable('favourites') ? 'favourites' : null); - if (! $favTable) { - return view('dashboard.favorites', ['artworks' => new LengthAwarePaginator([], 0, $perPage)]); - } - + $favTable = 'artwork_favourites'; $sort = $request->query('sort', 'newest'); $order = $sort === 'oldest' ? 'asc' : 'desc'; - - // Determine a column to order by (legacy 'datum' or modern timestamps) - $schema = DB::getSchemaBuilder(); - $orderColumn = null; - foreach (['datum', 'created_at', 'created', 'date'] as $col) { - if ($schema->hasColumn($favTable, $col)) { - $orderColumn = $col; - break; - } - } + $orderColumn = 'created_at'; $query = DB::table($favTable)->where('user_id', (int) $user->id); if ($orderColumn) { @@ -78,19 +66,18 @@ class FavoriteController extends Controller $artwork = (int) $last; } } - $favTable = DB::getSchemaBuilder()->hasTable('user_favorites') ? 'user_favorites' : (DB::getSchemaBuilder()->hasTable('favourites') ? 'favourites' : null); - if ($favTable) { - $artworkId = is_object($artwork) ? (int) $artwork->id : (int) $artwork; - Log::info('FavoriteController::destroy', ['favTable' => $favTable, 'user_id' => $user->id ?? null, 'artwork' => $artwork, 'artworkId' => $artworkId]); - $deleted = DB::table($favTable) - ->where('user_id', (int) $user->id) - ->where('artwork_id', $artworkId) - ->delete(); + $artworkId = is_object($artwork) ? (int) $artwork->id : (int) $artwork; + Log::info('FavoriteController::destroy', ['user_id' => $user->id ?? null, 'artworkId' => $artworkId]); + // Look up creator before deleting so we can decrement their counter + $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id'); - // Fallback: some schemas or test setups may not match user_id; try deleting by artwork_id alone - if (! $deleted) { - DB::table($favTable)->where('artwork_id', $artworkId)->delete(); - } + DB::table('artwork_favourites') + ->where('user_id', (int) $user->id) + ->where('artwork_id', $artworkId) + ->delete(); + + if ($creatorId) { + app(UserStatsService::class)->decrementFavoritesReceived($creatorId); } return redirect()->route('dashboard.favorites')->with('status', 'favourite-removed'); diff --git a/app/Http/Controllers/Dashboard/FollowerController.php b/app/Http/Controllers/Dashboard/FollowerController.php index 06f1cd1b..32f31c51 100644 --- a/app/Http/Controllers/Dashboard/FollowerController.php +++ b/app/Http/Controllers/Dashboard/FollowerController.php @@ -3,16 +3,46 @@ namespace App\Http\Controllers\Dashboard; use App\Http\Controllers\Controller; +use App\Support\AvatarUrl; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class FollowerController extends Controller { public function index(Request $request) { - $user = $request->user(); - // Minimal placeholder: real implementation should query followers table - $followers = []; + $user = $request->user(); + $perPage = 30; - return view('dashboard.followers', ['followers' => $followers]); + // People who follow $user (user_id = $user being followed) + $followers = DB::table('user_followers as uf') + ->join('users as u', 'u.id', '=', 'uf.follower_id') + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->where('uf.user_id', $user->id) + ->whereNull('u.deleted_at') + ->orderByDesc('uf.created_at') + ->select([ + 'u.id', 'u.username', 'u.name', + 'up.avatar_hash', + 'us.uploads_count', + 'uf.created_at as followed_at', + ]) + ->paginate($perPage) + ->withQueryString() + ->through(fn ($row) => (object) [ + 'id' => $row->id, + 'username' => $row->username, + 'uname' => $row->username ?? $row->name, + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), + 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), + 'uploads' => $row->uploads_count ?? 0, + 'followed_at' => $row->followed_at, + ]); + + return view('dashboard.followers', [ + 'followers' => $followers, + 'page_title' => 'My Followers', + ]); } } diff --git a/app/Http/Controllers/Dashboard/FollowingController.php b/app/Http/Controllers/Dashboard/FollowingController.php index 49b07d5c..10ec1091 100644 --- a/app/Http/Controllers/Dashboard/FollowingController.php +++ b/app/Http/Controllers/Dashboard/FollowingController.php @@ -3,16 +3,48 @@ namespace App\Http\Controllers\Dashboard; use App\Http\Controllers\Controller; +use App\Support\AvatarUrl; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class FollowingController extends Controller { public function index(Request $request) { - $user = $request->user(); - // Minimal placeholder: real implementation should query following relationships - $following = []; + $user = $request->user(); + $perPage = 30; - return view('dashboard.following', ['following' => $following]); + // People that $user follows (follower_id = $user) + $following = DB::table('user_followers as uf') + ->join('users as u', 'u.id', '=', 'uf.user_id') + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') + ->where('uf.follower_id', $user->id) + ->whereNull('u.deleted_at') + ->orderByDesc('uf.created_at') + ->select([ + 'u.id', 'u.username', 'u.name', + 'up.avatar_hash', + 'us.uploads_count', + 'us.followers_count', + 'uf.created_at as followed_at', + ]) + ->paginate($perPage) + ->withQueryString() + ->through(fn ($row) => (object) [ + 'id' => $row->id, + 'username' => $row->username, + 'uname' => $row->username ?? $row->name, + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), + 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), + 'uploads' => $row->uploads_count ?? 0, + 'followers_count'=> $row->followers_count ?? 0, + 'followed_at' => $row->followed_at, + ]); + + return view('dashboard.following', [ + 'following' => $following, + 'page_title' => 'People I Follow', + ]); } } diff --git a/app/Http/Controllers/Messaging/MessagesPageController.php b/app/Http/Controllers/Messaging/MessagesPageController.php new file mode 100644 index 00000000..6e46025f --- /dev/null +++ b/app/Http/Controllers/Messaging/MessagesPageController.php @@ -0,0 +1,22 @@ + $id, + ]); + } +} diff --git a/app/Http/Controllers/User/FavouritesController.php b/app/Http/Controllers/User/FavouritesController.php index 8be69d96..d81db1dd 100644 --- a/app/Http/Controllers/User/FavouritesController.php +++ b/app/Http/Controllers/User/FavouritesController.php @@ -3,17 +3,17 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Services\UserStatsService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; -use App\Models\UserFavorite; +use App\Models\ArtworkFavourite; class FavouritesController extends Controller { public function index(Request $request, $userId = null, $username = null) { - $userId = $userId ? (int)$userId : ($request->user()->id ?? null); + $userId = $userId ? (int) $userId : ($request->user()->id ?? null); $page = max(1, (int) $request->query('page', 1)); $hits = 20; @@ -23,99 +23,39 @@ class FavouritesController extends Controller $results = collect(); try { - $schema = DB::getSchemaBuilder(); + $query = ArtworkFavourite::with(['artwork.user']) + ->where('user_id', $userId) + ->orderByDesc('created_at') + ->orderByDesc('artwork_id'); + + $total = (int) $query->count(); + + $favorites = $query->skip($start)->take($hits)->get(); + + $results = $favorites->map(function ($fav) { + $art = $fav->artwork; + if (! $art) { + return null; + } + $item = (object) $art->toArray(); + $item->uname = $art->user?->username ?? $art->user?->name ?? null; + $item->datum = $fav->created_at; + return $item; + })->filter(); } catch (\Throwable $e) { - $schema = null; - } - - $userIdCol = Schema::hasColumn('users', 'user_id') ? 'user_id' : 'id'; - $userNameCol = null; - foreach (['uname', 'username', 'name'] as $col) { - if (Schema::hasColumn('users', $col)) { - $userNameCol = $col; - break; - } - } - - if ($schema && $schema->hasTable('user_favorites') && class_exists(UserFavorite::class)) { - try { - $query = UserFavorite::with(['artwork.user']) - ->where('user_id', $userId) - ->orderByDesc('created_at') - ->orderByDesc('artwork_id'); - - $total = (int) $query->count(); - - $favorites = $query->skip($start)->take($hits)->get(); - - $results = $favorites->map(function ($fav) use ($userNameCol) { - $art = $fav->artwork; - if (! $art) { - return null; - } - $item = (object) $art->toArray(); - $item->uname = ($userNameCol && isset($art->user)) ? ($art->user->{$userNameCol} ?? null) : null; - $item->datum = $fav->created_at; - return $item; - })->filter(); - } catch (\Throwable $e) { - $total = 0; - $results = collect(); - } - } else { - try { - if ($schema && $schema->hasTable('artworks_favourites')) { - $favTable = 'artworks_favourites'; - } elseif ($schema && $schema->hasTable('favourites')) { - $favTable = 'favourites'; - } else { - $favTable = null; - } - - if ($schema && $schema->hasTable('artworks')) { - $artTable = 'artworks'; - } elseif ($schema && $schema->hasTable('wallz')) { - $artTable = 'wallz'; - } else { - $artTable = null; - } - } catch (\Throwable $e) { - $favTable = null; - $artTable = null; - } - - if ($favTable && $artTable) { - try { - $total = (int) DB::table($favTable)->where('user_id', $userId)->count(); - - $t2JoinCol = 't2.' . $userIdCol; - $t2NameSelect = $userNameCol ? DB::raw("t2.{$userNameCol} as uname") : DB::raw("'' as uname"); - - $results = DB::table($favTable . ' as t1') - ->rightJoin($artTable . ' as t3', 't1.artwork_id', '=', 't3.id') - ->leftJoin('users as t2', 't3.user_id', '=', $t2JoinCol) - ->where('t1.user_id', $userId) - ->select('t3.*', $t2NameSelect, 't1.datum') - ->orderByDesc('t1.datum') - ->orderByDesc('t1.artwork_id') - ->skip($start) - ->take($hits) - ->get(); - } catch (\Throwable $e) { - $total = 0; - $results = collect(); - } - } + $total = 0; + $results = collect(); } $results = collect($results)->filter()->values()->transform(function ($row) { - $row->name = $row->name ?? ''; + $row->name = $row->name ?? $row->title ?? ''; $row->slug = $row->slug ?? Str::slug($row->name); - $row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int)$row->id) : null; + $row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int) $row->id) : null; return $row; }); - $page_title = ($username ?: ($userNameCol ? DB::table('users')->where($userIdCol, $userId)->value($userNameCol) : '')) . ' Favourites'; + $displayName = $username ?: (DB::table('users')->where('id', $userId)->value('username') ?? ''); + $page_title = $displayName . ' Favourites'; return view('user.favourites', [ 'results' => $results, @@ -134,9 +74,13 @@ class FavouritesController extends Controller abort(403); } - $favTable = Schema::hasTable('user_favorites') ? 'user_favorites' : (Schema::hasTable('artworks_favourites') ? 'artworks_favourites' : 'favourites'); + $creatorId = (int) DB::table('artworks')->where('id', (int) $artworkId)->value('user_id'); - DB::table($favTable)->where('user_id', (int)$userId)->where('artwork_id', (int)$artworkId)->delete(); + DB::table('artwork_favourites')->where('user_id', (int) $userId)->where('artwork_id', (int) $artworkId)->delete(); + + if ($creatorId) { + app(UserStatsService::class)->decrementFavoritesReceived($creatorId); + } return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites'); } diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index d133906d..e850d18d 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -8,9 +8,11 @@ use App\Models\Artwork; use App\Models\ProfileComment; use App\Models\User; use App\Services\ArtworkService; +use App\Services\FollowService; use App\Services\ThumbnailPresenter; use App\Services\ThumbnailService; use App\Services\UsernameApprovalService; +use App\Services\UserStatsService; use App\Support\AvatarUrl; use App\Support\UsernamePolicy; use Illuminate\Http\JsonResponse; @@ -29,6 +31,8 @@ class ProfileController extends Controller public function __construct( private readonly ArtworkService $artworkService, private readonly UsernameApprovalService $usernameApprovalService, + private readonly FollowService $followService, + private readonly UserStatsService $userStats, ) { } @@ -73,35 +77,15 @@ class ProfileController extends Controller public function toggleFollow(Request $request, string $username): JsonResponse { $normalized = UsernamePolicy::normalize($username); - $target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); + $target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); + $actorId = (int) Auth::id(); - $viewerId = Auth::id(); - - if ($viewerId === $target->id) { + if ($actorId === $target->id) { return response()->json(['error' => 'Cannot follow yourself.'], 422); } - $exists = DB::table('user_followers') - ->where('user_id', $target->id) - ->where('follower_id', $viewerId) - ->exists(); - - if ($exists) { - DB::table('user_followers') - ->where('user_id', $target->id) - ->where('follower_id', $viewerId) - ->delete(); - $following = false; - } else { - DB::table('user_followers')->insertOrIgnore([ - 'user_id' => $target->id, - 'follower_id'=> $viewerId, - 'created_at' => now(), - ]); - $following = true; - } - - $count = DB::table('user_followers')->where('user_id', $target->id)->count(); + $following = $this->followService->toggle($actorId, (int) $target->id); + $count = $this->followService->followersCount((int) $target->id); return response()->json([ 'following' => $following, @@ -510,11 +494,7 @@ class ProfileController extends Controller // ── Increment profile views (async-safe, ignore errors) ────────────── if (! $isOwner) { try { - DB::table('user_statistics') - ->updateOrInsert( - ['user_id' => $user->id], - ['profile_views' => DB::raw('COALESCE(profile_views, 0) + 1'), 'updated_at' => now()] - ); + $this->userStats->incrementProfileViews($user->id); } catch (\Throwable) {} } diff --git a/app/Http/Controllers/User/TodayInHistoryController.php b/app/Http/Controllers/User/TodayInHistoryController.php index ff3d6064..04eeb4e5 100644 --- a/app/Http/Controllers/User/TodayInHistoryController.php +++ b/app/Http/Controllers/User/TodayInHistoryController.php @@ -3,51 +3,87 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Models\Artwork; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class TodayInHistoryController extends Controller { public function index(Request $request) { - $hits = 39; + $perPage = 36; + $artworks = null; + $today = now(); - try { - $base = DB::table('featured_works as t0') - ->leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id') - ->join('categories as t2', 't1.category', '=', 't2.id') - ->where('t1.approved', 1) - ->whereRaw('MONTH(t0.post_date) = MONTH(CURRENT_DATE())') - ->whereRaw('DAY(t0.post_date) = DAY(CURRENT_DATE())') - ->select('t1.id', 't1.name', 't1.picture', 't1.uname', 't1.category', DB::raw('t2.name as category_name')); + // ── Strategy 1: legacy featured_works table (historical data from old site) ─ + $hasFeaturedWorks = false; + try { $hasFeaturedWorks = Schema::hasTable('featured_works'); } catch (\Throwable) {} - $artworks = $base->orderBy('t0.post_date','desc')->paginate($hits); - } catch (\Throwable $e) { - $artworks = null; + if ($hasFeaturedWorks) { + try { + $artworks = DB::table('featured_works as f') + ->join('artworks as a', 'f.artwork_id', '=', 'a.id') + ->where('a.is_approved', true) + ->where('a.is_public', true) + ->whereNull('a.deleted_at') + ->whereRaw('MONTH(f.post_date) = ?', [$today->month]) + ->whereRaw('DAY(f.post_date) = ?', [$today->day]) + ->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext', + DB::raw('f.post_date as featured_date')) + ->orderBy('f.post_date', 'desc') + ->paginate($perPage); + } catch (\Throwable $e) { + $artworks = null; + } } - if ($artworks && method_exists($artworks, 'getCollection')) { - $artworks->getCollection()->transform(function ($row) { - $row->ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; - $row->encoded = \App\Services\LegacyService::encode($row->id); - try { - $art = \App\Models\Artwork::find($row->id); - $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); - $row->thumb_url = $present['url']; - $row->thumb_srcset = $present['srcset']; - } catch (\Throwable $e) { - $present = \App\Services\ThumbnailPresenter::present((array) $row, 'md'); - $row->thumb_url = $present['url']; - $row->thumb_srcset = $present['srcset']; + // ── Strategy 2: new artwork_features table ─────────────────────────────── + if (!$artworks || $artworks->total() === 0) { + try { + $artworks = DB::table('artwork_features as f') + ->join('artworks as a', 'f.artwork_id', '=', 'a.id') + ->where('f.is_active', true) + ->where('a.is_approved', true) + ->where('a.is_public', true) + ->whereNull('a.deleted_at') + ->whereNotNull('a.published_at') + ->whereRaw('MONTH(f.featured_at) = ?', [$today->month]) + ->whereRaw('DAY(f.featured_at) = ?', [$today->day]) + ->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext', + DB::raw('f.featured_at as featured_date')) + ->orderBy('f.featured_at', 'desc') + ->paginate($perPage); + } catch (\Throwable $e) { + $artworks = null; + } + } + + // ── Enrich with CDN thumbnails (batch load to avoid N+1) ───────────────── + if ($artworks && method_exists($artworks, 'getCollection') && $artworks->count() > 0) { + $ids = $artworks->getCollection()->pluck('id')->all(); + $modelsById = Artwork::whereIn('id', $ids)->get()->keyBy('id'); + + $artworks->getCollection()->transform(function ($row) use ($modelsById) { + /** @var ?Artwork $art */ + $art = $modelsById->get($row->id); + if ($art) { + $row->thumb_url = $art->thumbUrl('md') ?? '/gfx/sb_join.jpg'; + $row->art_url = '/art/' . $art->id . '/' . $art->slug; + $row->name = $art->title ?: ($row->name ?? 'Untitled'); + } else { + $row->thumb_url = '/gfx/sb_join.jpg'; + $row->art_url = '/art/' . $row->id; + $row->name = $row->name ?? 'Untitled'; } - $row->gid_num = ((int)($row->category ?? 0) % 5) * 5; return $row; }); } return view('legacy::today-in-history', [ - 'artworks' => $artworks, + 'artworks' => $artworks, 'page_title' => 'Popular on this day in history', + 'todayLabel' => $today->format('F j'), ]); } } diff --git a/app/Http/Controllers/User/TopFavouritesController.php b/app/Http/Controllers/User/TopFavouritesController.php index e715538b..adcfbb44 100644 --- a/app/Http/Controllers/User/TopFavouritesController.php +++ b/app/Http/Controllers/User/TopFavouritesController.php @@ -15,11 +15,11 @@ class TopFavouritesController extends Controller $hits = 21; $page = max(1, (int) $request->query('page', 1)); - $base = DB::table('artworks_favourites as t1') - ->rightJoin('wallz as t2', 't1.artwork_id', '=', 't2.id') - ->where('t2.approved', 1) - ->select('t2.id', 't2.name', 't2.picture', 't2.category', DB::raw('COUNT(*) as num')) - ->groupBy('t1.artwork_id'); + $base = DB::table('artwork_favourites as t1') + ->join('artworks as t2', 't1.artwork_id', '=', 't2.id') + ->whereNotNull('t2.published_at') + ->select('t2.id', 't2.title as name', 't2.slug', DB::raw('NULL as picture'), DB::raw('NULL as category'), DB::raw('COUNT(*) as num')) + ->groupBy('t2.id', 't2.title', 't2.slug'); try { $paginator = (clone $base)->orderBy('num', 'desc')->paginate($hits)->withQueryString(); diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php new file mode 100644 index 00000000..08f3b68b --- /dev/null +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -0,0 +1,243 @@ +searchService->discoverTrending($perPage); + $artworks = $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + + return view('web.discover.index', [ + 'artworks' => $results, + 'page_title' => 'Trending Artworks', + 'section' => 'trending', + 'description' => 'The most-viewed artworks on Skinbase over the past 7 days.', + 'icon' => 'fa-fire', + ]); + } + + // ─── /discover/fresh ───────────────────────────────────────────────────── + + public function fresh(Request $request) + { + $perPage = 24; + $results = $this->searchService->discoverFresh($perPage); + $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + + return view('web.discover.index', [ + 'artworks' => $results, + 'page_title' => 'Fresh Uploads', + 'section' => 'fresh', + 'description' => 'The latest artworks just uploaded to Skinbase.', + 'icon' => 'fa-bolt', + ]); + } + + // ─── /discover/top-rated ───────────────────────────────────────────────── + + public function topRated(Request $request) + { + $perPage = 24; + $results = $this->searchService->discoverTopRated($perPage); + $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + + return view('web.discover.index', [ + 'artworks' => $results, + 'page_title' => 'Top Rated Artworks', + 'section' => 'top-rated', + 'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.', + 'icon' => 'fa-medal', + ]); + } + + // ─── /discover/most-downloaded ─────────────────────────────────────────── + + public function mostDownloaded(Request $request) + { + $perPage = 24; + $results = $this->searchService->discoverMostDownloaded($perPage); + $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + + return view('web.discover.index', [ + 'artworks' => $results, + 'page_title' => 'Most Downloaded', + 'section' => 'most-downloaded', + 'description' => 'All-time most downloaded artworks on Skinbase.', + 'icon' => 'fa-download', + ]); + } + + // ─── /discover/on-this-day ─────────────────────────────────────────────── + + public function onThisDay(Request $request) + { + $perPage = 24; + $today = now(); + + $artworks = Artwork::query() + ->public() + ->published() + ->with(['user:id,name', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) + ->whereRaw('MONTH(published_at) = ?', [$today->month]) + ->whereRaw('DAY(published_at) = ?', [$today->day]) + ->whereRaw('YEAR(published_at) < ?', [$today->year]) + ->orderByDesc('published_at') + ->paginate($perPage) + ->withQueryString(); + + $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + + return view('web.discover.index', [ + 'artworks' => $artworks, + 'page_title' => 'On This Day', + 'section' => 'on-this-day', + 'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.', + 'icon' => 'fa-calendar-day', + ]); + } + + // ─── /creators/rising ──────────────────────────────────────────────────── + + public function risingCreators(Request $request) + { + $perPage = 20; + + // Creators with artworks published in the last 90 days, ordered by total recent views. + $hasStats = false; + try { $hasStats = Schema::hasTable('artwork_stats'); } catch (\Throwable) {} + + if ($hasStats) { + $sub = Artwork::query() + ->public() + ->published() + ->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id') + ->where('artworks.published_at', '>=', now()->subDays(90)) + ->selectRaw('artworks.user_id, SUM(artwork_stats.views) as recent_views, MAX(artworks.published_at) as latest_published') + ->groupBy('artworks.user_id'); + } else { + $sub = Artwork::query() + ->public() + ->published() + ->where('published_at', '>=', now()->subDays(90)) + ->selectRaw('user_id, COUNT(*) as recent_views, MAX(published_at) as latest_published') + ->groupBy('user_id'); + } + + $creators = DB::table(DB::raw('(' . $sub->toSql() . ') as t')) + ->mergeBindings($sub->getQuery()) + ->join('users as u', 'u.id', '=', 't.user_id') + ->select('u.id as user_id', 'u.name as uname', 'u.username', 't.recent_views', 't.latest_published') + ->orderByDesc('t.recent_views') + ->orderByDesc('t.latest_published') + ->paginate($perPage) + ->withQueryString(); + + $creators->getCollection()->transform(function ($row) { + return (object) [ + 'user_id' => $row->user_id, + 'uname' => $row->uname, + 'username' => $row->username, + 'total' => (int) $row->recent_views, + 'metric' => 'views', + ]; + }); + + return view('web.creators.rising', [ + 'creators' => $creators, + 'page_title' => 'Rising Creators — Skinbase', + ]); + } + + // ─── /discover/following ───────────────────────────────────────────────── + + public function following(Request $request) + { + $user = $request->user(); + $perPage = 24; + + // Subquery: IDs of users this viewer follows + $followingIds = DB::table('user_followers') + ->where('follower_id', $user->id) + ->pluck('user_id'); + + if ($followingIds->isEmpty()) { + $artworks = Artwork::query()->paginate(0); + + return view('web.discover.index', [ + 'artworks' => $artworks, + 'page_title' => 'Following Feed', + 'section' => 'following', + 'description' => 'Follow some creators to see their work here.', + 'icon' => 'fa-user-group', + 'empty' => true, + ]); + } + + $artworks = Artwork::query() + ->public() + ->published() + ->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order']) + ->whereIn('user_id', $followingIds) + ->orderByDesc('published_at') + ->paginate($perPage) + ->withQueryString(); + + $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); + + return view('web.discover.index', [ + 'artworks' => $artworks, + 'page_title' => 'Following Feed', + 'section' => 'following', + 'description' => 'The latest artworks from creators you follow.', + 'icon' => 'fa-user-group', + ]); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private function presentArtwork(Artwork $artwork): object + { + $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); + $present = ThumbnailPresenter::present($artwork, 'md'); + + return (object) [ + 'id' => $artwork->id, + 'name' => $artwork->title, + 'category_name' => $primaryCategory->name ?? '', + 'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0, + 'thumb_url' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $artwork->user->name ?? 'Skinbase', + 'published_at' => $artwork->published_at, + ]; + } +} diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index e7f35e29..00f0846f 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -15,6 +15,21 @@ final class TagController extends Controller { public function __construct(private readonly ArtworkSearchService $search) {} + public function index(Request $request): View + { + $tags = \App\Models\Tag::withCount('artworks') + ->orderByDesc('artworks_count') + ->paginate(80) + ->withQueryString(); + + return view('web.tags.index', [ + 'tags' => $tags, + 'page_title' => 'Browse Tags — Skinbase', + 'page_canonical' => route('tags.index'), + 'page_robots' => 'index,follow', + ]); + } + public function show(Tag $tag, Request $request): View { $sort = $request->query('sort', 'popular'); // popular | latest | downloads diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index ce4dc8df..a5ecc220 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -43,12 +43,10 @@ class ArtworkResource extends JsonResource ->exists(); } - if (Schema::hasTable('user_favorites')) { - $isFavorited = DB::table('user_favorites') - ->where('user_id', $viewerId) - ->where('artwork_id', (int) $this->id) - ->exists(); - } + $isFavorited = DB::table('artwork_favourites') + ->where('user_id', $viewerId) + ->where('artwork_id', (int) $this->id) + ->exists(); if (Schema::hasTable('friends_list') && !empty($this->user?->id)) { $isFollowing = DB::table('friends_list') diff --git a/app/Jobs/DeleteMessageFromIndexJob.php b/app/Jobs/DeleteMessageFromIndexJob.php new file mode 100644 index 00000000..7a7c2d6f --- /dev/null +++ b/app/Jobs/DeleteMessageFromIndexJob.php @@ -0,0 +1,25 @@ +whereKey($this->messageId)->unsearchable(); + } +} diff --git a/app/Jobs/IndexMessageJob.php b/app/Jobs/IndexMessageJob.php new file mode 100644 index 00000000..7055e117 --- /dev/null +++ b/app/Jobs/IndexMessageJob.php @@ -0,0 +1,36 @@ +find($this->messageId); + + if (! $message) { + return; + } + + if (! $message->shouldBeSearchable()) { + $message->unsearchable(); + return; + } + + $message->searchable(); + } +} diff --git a/app/Jobs/IndexUserJob.php b/app/Jobs/IndexUserJob.php new file mode 100644 index 00000000..df0be793 --- /dev/null +++ b/app/Jobs/IndexUserJob.php @@ -0,0 +1,42 @@ +find($this->userId); + + if (! $user) { + return; + } + + if (! $user->shouldBeSearchable()) { + $user->unsearchable(); + return; + } + + $user->searchable(); + } +} diff --git a/app/Jobs/RecomputeUserStatsJob.php b/app/Jobs/RecomputeUserStatsJob.php new file mode 100644 index 00000000..4e1675c5 --- /dev/null +++ b/app/Jobs/RecomputeUserStatsJob.php @@ -0,0 +1,36 @@ + $userIds + */ + public function __construct(public readonly array $userIds) {} + + public function handle(UserStatsService $statsService): void + { + foreach ($this->userIds as $userId) { + $statsService->recomputeUser((int) $userId); + } + } +} diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index aedae5fb..8384c7cd 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -174,6 +174,20 @@ class Artwork extends Model return $this->hasMany(ArtworkFeature::class, 'artwork_id'); } + /** All favourite pivot rows for this artwork. */ + public function favourites(): HasMany + { + return $this->hasMany(ArtworkFavourite::class, 'artwork_id'); + } + + /** Users who have favourited this artwork (many-to-many shortcut). */ + public function favouritedBy(): BelongsToMany + { + return $this->belongsToMany(User::class, 'artwork_favourites', 'artwork_id', 'user_id') + ->withPivot('legacy_id') + ->withTimestamps(); + } + public function awards(): HasMany { return $this->hasMany(ArtworkAward::class); diff --git a/app/Models/ArtworkComment.php b/app/Models/ArtworkComment.php index 9753f160..3e9bca77 100644 --- a/app/Models/ArtworkComment.php +++ b/app/Models/ArtworkComment.php @@ -1,19 +1,29 @@ belongsTo(User::class); } + + public function reactions(): HasMany + { + return $this->hasMany(CommentReaction::class, 'comment_id'); + } + + /** + * Return the best available rendered content for display. + * Falls back to escaping raw legacy content if rendering isn't done yet. + */ + public function getDisplayHtml(): string + { + if ($this->rendered_content !== null) { + return $this->rendered_content; + } + + // Lazy render: raw_content takes priority over legacy content + $raw = $this->raw_content ?? $this->content ?? ''; + return \App\Services\ContentSanitizer::render($raw); + } } diff --git a/app/Models/ArtworkFavourite.php b/app/Models/ArtworkFavourite.php new file mode 100644 index 00000000..f814bef7 --- /dev/null +++ b/app/Models/ArtworkFavourite.php @@ -0,0 +1,48 @@ + 'integer', + 'artwork_id' => 'integer', + 'legacy_id' => 'integer', + ]; + + // ── Relations ────────────────────────────────────────────────────────── + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } +} diff --git a/app/Models/ArtworkReaction.php b/app/Models/ArtworkReaction.php new file mode 100644 index 00000000..66a11d2a --- /dev/null +++ b/app/Models/ArtworkReaction.php @@ -0,0 +1,37 @@ + 'integer', + 'user_id' => 'integer', + 'created_at' => 'datetime', + ]; + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/CommentReaction.php b/app/Models/CommentReaction.php new file mode 100644 index 00000000..d8a74537 --- /dev/null +++ b/app/Models/CommentReaction.php @@ -0,0 +1,37 @@ + 'integer', + 'user_id' => 'integer', + 'created_at' => 'datetime', + ]; + + public function comment(): BelongsTo + { + return $this->belongsTo(ArtworkComment::class, 'comment_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php new file mode 100644 index 00000000..51605155 --- /dev/null +++ b/app/Models/Conversation.php @@ -0,0 +1,117 @@ + 'datetime', + ]; + + // ── Relationships ──────────────────────────────────────────────────────── + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function participants(): BelongsToMany + { + return $this->belongsToMany(User::class, 'conversation_participants') + ->withPivot(['role', 'last_read_at', 'is_muted', 'is_archived', 'is_pinned', 'pinned_at', 'joined_at', 'left_at']) + ->wherePivotNull('left_at'); + } + + public function allParticipants(): HasMany + { + return $this->hasMany(ConversationParticipant::class); + } + + public function messages(): HasMany + { + return $this->hasMany(Message::class)->orderBy('created_at'); + } + + public function latestMessage(): HasOne + { + return $this->hasOne(Message::class)->whereNull('deleted_at')->latestOfMany(); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + public function isDirect(): bool + { + return $this->type === 'direct'; + } + + public function isGroup(): bool + { + return $this->type === 'group'; + } + + /** + * Find an existing direct conversation between exactly two users, or null. + */ + public static function findDirect(int $userA, int $userB): ?self + { + return self::query() + ->where('type', 'direct') + ->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userA)->whereNull('left_at')) + ->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at')) + ->whereRaw( + '(select count(*) from conversation_participants' + .' where conversation_participants.conversation_id = conversations.id' + .' and left_at is null) = 2' + ) + ->first(); + } + + /** + * Compute unread count for a given participant. + */ + public function unreadCountFor(int $userId): int + { + $participant = $this->allParticipants() + ->where('user_id', $userId) + ->first(); + + if (! $participant) { + return 0; + } + + $query = $this->messages() + ->whereNull('deleted_at') + ->where('sender_id', '!=', $userId); + + if ($participant->last_read_at) { + $query->where('created_at', '>', $participant->last_read_at); + } + + return $query->count(); + } +} diff --git a/app/Models/ConversationParticipant.php b/app/Models/ConversationParticipant.php new file mode 100644 index 00000000..40ea4864 --- /dev/null +++ b/app/Models/ConversationParticipant.php @@ -0,0 +1,62 @@ + 'datetime', + 'is_muted' => 'boolean', + 'is_archived' => 'boolean', + 'is_pinned' => 'boolean', + 'pinned_at' => 'datetime', + 'joined_at' => 'datetime', + 'left_at' => 'datetime', + ]; + + // ── Relationships ──────────────────────────────────────────────────────── + + public function conversation(): BelongsTo + { + return $this->belongsTo(Conversation::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Message.php b/app/Models/Message.php new file mode 100644 index 00000000..aedcbf9b --- /dev/null +++ b/app/Models/Message.php @@ -0,0 +1,89 @@ + 'datetime', + ]; + + // ── Relationships ──────────────────────────────────────────────────────── + + public function conversation(): BelongsTo + { + return $this->belongsTo(Conversation::class); + } + + public function sender(): BelongsTo + { + return $this->belongsTo(User::class, 'sender_id'); + } + + public function reactions(): HasMany + { + return $this->hasMany(MessageReaction::class); + } + + public function attachments(): HasMany + { + return $this->hasMany(MessageAttachment::class); + } + + public function setBodyAttribute(string $value): void + { + $sanitized = trim(strip_tags($value)); + $this->attributes['body'] = $sanitized; + } + + public function searchableAs(): string + { + return config('messaging.search.index', 'messages'); + } + + public function shouldBeSearchable(): bool + { + return $this->deleted_at === null; + } + + public function toSearchableArray(): array + { + return [ + 'id' => (int) $this->id, + 'conversation_id' => (int) $this->conversation_id, + 'sender_id' => (int) $this->sender_id, + 'sender_username' => (string) ($this->sender?->username ?? ''), + 'body_text' => trim(strip_tags((string) $this->body)), + 'created_at' => optional($this->created_at)->timestamp ?? now()->timestamp, + 'has_attachments' => $this->relationLoaded('attachments') + ? $this->attachments->isNotEmpty() + : $this->attachments()->exists(), + ]; + } +} diff --git a/app/Models/MessageAttachment.php b/app/Models/MessageAttachment.php new file mode 100644 index 00000000..9aa8fd23 --- /dev/null +++ b/app/Models/MessageAttachment.php @@ -0,0 +1,45 @@ + 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'created_at' => 'datetime', + ]; + + public function message(): BelongsTo + { + return $this->belongsTo(Message::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/MessageReaction.php b/app/Models/MessageReaction.php new file mode 100644 index 00000000..1d7b8742 --- /dev/null +++ b/app/Models/MessageReaction.php @@ -0,0 +1,43 @@ + 'datetime', + ]; + + // ── Relationships ──────────────────────────────────────────────────────── + + public function message(): BelongsTo + { + return $this->belongsTo(Message::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Report.php b/app/Models/Report.php new file mode 100644 index 00000000..535ff14b --- /dev/null +++ b/app/Models/Report.php @@ -0,0 +1,26 @@ +belongsTo(User::class, 'reporter_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index bec58320..a6fe71a4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,14 +7,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasMany; +use App\Models\Conversation; +use App\Models\ConversationParticipant; +use App\Models\Message; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\DB; +use Laravel\Scout\Searchable; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, SoftDeletes; + use HasFactory, Notifiable, SoftDeletes, Searchable; /** * The attributes that are mass assignable. @@ -34,6 +39,7 @@ class User extends Authenticatable 'needs_password_reset', 'password', 'role', + 'allow_messages_from', ]; /** @@ -61,6 +67,7 @@ class User extends Authenticatable 'username_changed_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', + 'allow_messages_from' => 'string', ]; } @@ -106,11 +113,93 @@ class User extends Authenticatable return $this->hasMany(ProfileComment::class, 'profile_user_id'); } + // ── Messaging ──────────────────────────────────────────────────────────── + + public function conversations(): BelongsToMany + { + return $this->belongsToMany(Conversation::class, 'conversation_participants') + ->withPivot(['role', 'last_read_at', 'is_muted', 'is_archived', 'is_pinned', 'pinned_at', 'joined_at', 'left_at']) + ->wherePivotNull('left_at') + ->orderByPivot('joined_at', 'desc'); + } + + public function conversationParticipants(): HasMany + { + return $this->hasMany(ConversationParticipant::class); + } + + public function sentMessages(): HasMany + { + return $this->hasMany(Message::class, 'sender_id'); + } + + /** + * Check if this user allows receiving messages from the given user. + */ + public function allowsMessagesFrom(User $sender): bool + { + $pref = $this->allow_messages_from ?? 'everyone'; + + return match ($pref) { + 'everyone' => true, + 'followers' => $this->followers()->where('follower_id', $sender->id)->exists(), + 'mutual_followers' => $this->followers()->where('follower_id', $sender->id)->exists() + && $this->following()->where('user_id', $sender->id)->exists(), + 'nobody' => false, + default => true, + }; + } + + // ──────────────────────────────────────────────────────────────────────── + + /** Artworks this user has added to their favourites. */ + public function favouriteArtworks(): BelongsToMany + { + return $this->belongsToMany(Artwork::class, 'artwork_favourites', 'user_id', 'artwork_id') + ->withPivot('legacy_id') + ->withTimestamps(); + } + public function hasRole(string $role): bool { return strtolower((string) ($this->role ?? '')) === strtolower($role); } + // ─── Follow helpers ─────────────────────────────────────────────────────── + + /** + * Whether $viewerId is following this user. + * Uses a single indexed lookup – safe to call on every profile render. + */ + public function isFollowedBy(int $viewerId): bool + { + if ($viewerId === $this->id) { + return false; + } + + return DB::table('user_followers') + ->where('user_id', $this->id) + ->where('follower_id', $viewerId) + ->exists(); + } + + /** + * Cached follower count from user_statistics. + * Returns 0 if the statistics row does not exist yet. + */ + public function getFollowersCountAttribute(): int + { + return (int) ($this->statistics?->followers_count ?? 0); + } + + /** + * Cached following count from user_statistics. + */ + public function getFollowingCountAttribute(): int + { + return (int) ($this->statistics?->following_count ?? 0); + } + public function isAdmin(): bool { return $this->hasRole('admin'); @@ -120,4 +209,42 @@ class User extends Authenticatable { return $this->hasRole('moderator'); } + + // ─── Meilisearch ────────────────────────────────────────────────────────── + + /** + * Only index active users (not soft-deleted, is_active = true). + */ + public function shouldBeSearchable(): bool + { + return (bool) $this->is_active && ! $this->trashed(); + } + + /** + * Data indexed in Meilisearch. + * Includes all v2 stat counters for top-creator sorting. + */ + public function toSearchableArray(): array + { + $stats = $this->statistics; + + return [ + 'id' => $this->id, + 'username' => strtolower((string) ($this->username ?? '')), + 'name' => $this->name, + // Upload activity + 'uploads_count' => (int) ($stats?->uploads_count ?? 0), + // Creator-received metrics + 'downloads_received_count' => (int) ($stats?->downloads_received_count ?? 0), + 'artwork_views_received_count' => (int) ($stats?->artwork_views_received_count ?? 0), + 'awards_received_count' => (int) ($stats?->awards_received_count ?? 0), + 'favorites_received_count' => (int) ($stats?->favorites_received_count ?? 0), + 'comments_received_count' => (int) ($stats?->comments_received_count ?? 0), + 'reactions_received_count' => (int) ($stats?->reactions_received_count ?? 0), + // Social + 'followers_count' => (int) ($stats?->followers_count ?? 0), + 'following_count' => (int) ($stats?->following_count ?? 0), + 'created_at' => $this->created_at?->toISOString(), + ]; + } } diff --git a/app/Models/UserStatistic.php b/app/Models/UserStatistic.php index 70ebd1d7..a07d4831 100644 --- a/app/Models/UserStatistic.php +++ b/app/Models/UserStatistic.php @@ -1,10 +1,20 @@ 'integer', + 'uploads_count' => 'integer', + 'downloads_received_count' => 'integer', + 'artwork_views_received_count' => 'integer', + 'awards_received_count' => 'integer', + 'favorites_received_count' => 'integer', + 'comments_received_count' => 'integer', + 'reactions_received_count' => 'integer', + 'followers_count' => 'integer', + 'following_count' => 'integer', + 'profile_views_count' => 'integer', + 'last_upload_at' => 'datetime', + 'last_active_at' => 'datetime', ]; public $timestamps = true; diff --git a/app/Observers/ArtworkAwardObserver.php b/app/Observers/ArtworkAwardObserver.php index cffc0b3b..a621ffc1 100644 --- a/app/Observers/ArtworkAwardObserver.php +++ b/app/Observers/ArtworkAwardObserver.php @@ -6,26 +6,32 @@ namespace App\Observers; use App\Models\ArtworkAward; use App\Services\ArtworkAwardService; +use App\Services\UserStatsService; +use Illuminate\Support\Facades\DB; class ArtworkAwardObserver { public function __construct( - private readonly ArtworkAwardService $service + private readonly ArtworkAwardService $service, + private readonly UserStatsService $userStats, ) {} public function created(ArtworkAward $award): void { $this->refresh($award); + $this->trackCreatorStats($award, +1); } public function updated(ArtworkAward $award): void { $this->refresh($award); + // Medal changed – count stays the same; no stat change needed. } public function deleted(ArtworkAward $award): void { $this->refresh($award); + $this->trackCreatorStats($award, -1); } private function refresh(ArtworkAward $award): void @@ -37,4 +43,21 @@ class ArtworkAwardObserver $this->service->syncToSearch($artwork); } } + + private function trackCreatorStats(ArtworkAward $award, int $delta): void + { + $creatorId = DB::table('artworks') + ->where('id', $award->artwork_id) + ->value('user_id'); + + if (! $creatorId) { + return; + } + + if ($delta > 0) { + $this->userStats->incrementAwardsReceived((int) $creatorId); + } else { + $this->userStats->decrementAwardsReceived((int) $creatorId); + } + } } diff --git a/app/Observers/ArtworkCommentObserver.php b/app/Observers/ArtworkCommentObserver.php new file mode 100644 index 00000000..06e39a22 --- /dev/null +++ b/app/Observers/ArtworkCommentObserver.php @@ -0,0 +1,63 @@ +creatorId($comment->artwork_id); + if ($creatorId) { + $this->userStats->incrementCommentsReceived($creatorId); + } + + // The commenter is "active" + $this->userStats->ensureRow($comment->user_id); + $this->userStats->setLastActiveAt($comment->user_id); + } + + /** Soft delete. */ + public function deleted(ArtworkComment $comment): void + { + $creatorId = $this->creatorId($comment->artwork_id); + if ($creatorId) { + $this->userStats->decrementCommentsReceived($creatorId); + } + } + + /** Hard delete after soft delete — already decremented; nothing to do. */ + public function forceDeleted(ArtworkComment $comment): void + { + // Only decrement if the comment was NOT already soft-deleted + // (to avoid double-decrement). + if ($comment->deleted_at === null) { + $creatorId = $this->creatorId($comment->artwork_id); + if ($creatorId) { + $this->userStats->decrementCommentsReceived($creatorId); + } + } + } + + private function creatorId(int $artworkId): ?int + { + $id = DB::table('artworks') + ->where('id', $artworkId) + ->value('user_id'); + + return $id !== null ? (int) $id : null; + } +} diff --git a/app/Observers/ArtworkFavouriteObserver.php b/app/Observers/ArtworkFavouriteObserver.php new file mode 100644 index 00000000..1d862478 --- /dev/null +++ b/app/Observers/ArtworkFavouriteObserver.php @@ -0,0 +1,45 @@ +creatorId($favourite->artwork_id); + if ($creatorId) { + $this->userStats->incrementFavoritesReceived($creatorId); + } + } + + public function deleted(ArtworkFavourite $favourite): void + { + $creatorId = $this->creatorId($favourite->artwork_id); + if ($creatorId) { + $this->userStats->decrementFavoritesReceived($creatorId); + } + } + + private function creatorId(int $artworkId): ?int + { + $id = DB::table('artworks') + ->where('id', $artworkId) + ->value('user_id'); + + return $id !== null ? (int) $id : null; + } +} diff --git a/app/Observers/ArtworkObserver.php b/app/Observers/ArtworkObserver.php index 5458f065..8d75d41a 100644 --- a/app/Observers/ArtworkObserver.php +++ b/app/Observers/ArtworkObserver.php @@ -6,22 +6,27 @@ namespace App\Observers; use App\Models\Artwork; use App\Services\ArtworkSearchIndexer; +use App\Services\UserStatsService; /** * Syncs artwork documents to Meilisearch on every relevant model event. + * Also keeps user_statistics.uploads_count and last_upload_at in sync. * * All operations are dispatched to the queue — no blocking calls. */ class ArtworkObserver { public function __construct( - private readonly ArtworkSearchIndexer $indexer + private readonly ArtworkSearchIndexer $indexer, + private readonly UserStatsService $userStats, ) {} - /** New artwork created — index once published and approved. */ + /** New artwork created — index; bump uploadscount + last_upload_at. */ public function created(Artwork $artwork): void { $this->indexer->index($artwork); + $this->userStats->incrementUploads($artwork->user_id); + $this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at); } /** Artwork updated — covers publish, approval, metadata changes. */ @@ -36,21 +41,29 @@ class ArtworkObserver $this->indexer->update($artwork); } - /** Soft delete — remove from search. */ + /** Soft delete — remove from search and decrement uploads_count. */ public function deleted(Artwork $artwork): void { $this->indexer->delete($artwork->id); + $this->userStats->decrementUploads($artwork->user_id); } - /** Force delete — ensure removal from index. */ + /** Force delete — ensure removal from index; only decrement if NOT already soft-deleted. */ public function forceDeleted(Artwork $artwork): void { $this->indexer->delete($artwork->id); + + // If deleted_at was null the artwork was not soft-deleted before; + // the deleted() event did NOT fire, so we decrement here. + if ($artwork->deleted_at === null) { + $this->userStats->decrementUploads($artwork->user_id); + } } - /** Restored from soft-delete — re-index. */ + /** Restored from soft-delete — re-index and re-increment uploads_count. */ public function restored(Artwork $artwork): void { $this->indexer->index($artwork); + $this->userStats->incrementUploads($artwork->user_id); } } diff --git a/app/Observers/ArtworkReactionObserver.php b/app/Observers/ArtworkReactionObserver.php new file mode 100644 index 00000000..4c191f29 --- /dev/null +++ b/app/Observers/ArtworkReactionObserver.php @@ -0,0 +1,49 @@ +creatorId($reaction->artwork_id); + if ($creatorId) { + $this->userStats->incrementReactionsReceived($creatorId); + } + + // The reactor is "active" + $this->userStats->ensureRow($reaction->user_id); + $this->userStats->setLastActiveAt($reaction->user_id); + } + + public function deleted(ArtworkReaction $reaction): void + { + $creatorId = $this->creatorId($reaction->artwork_id); + if ($creatorId) { + $this->userStats->decrementReactionsReceived($creatorId); + } + } + + private function creatorId(int $artworkId): ?int + { + $id = DB::table('artworks') + ->where('id', $artworkId) + ->value('user_id'); + + return $id !== null ? (int) $id : null; + } +} diff --git a/app/Policies/ArtworkCommentPolicy.php b/app/Policies/ArtworkCommentPolicy.php new file mode 100644 index 00000000..ac935d5b --- /dev/null +++ b/app/Policies/ArtworkCommentPolicy.php @@ -0,0 +1,25 @@ +id === (int) $comment->user_id; + } + + /** + * Users can delete their own comments; admins can delete any comment. + */ + public function delete(User $user, ArtworkComment $comment): bool + { + return $user->id === (int) $comment->user_id || $user->is_admin; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8b14ea93..c9586825 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,10 +6,16 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; -use App\Models\ArtworkAward; -use App\Observers\ArtworkAwardObserver; use App\Models\Artwork; +use App\Models\ArtworkAward; +use App\Models\ArtworkComment; +use App\Models\ArtworkFavourite; +use App\Models\ArtworkReaction; +use App\Observers\ArtworkAwardObserver; +use App\Observers\ArtworkCommentObserver; +use App\Observers\ArtworkFavouriteObserver; use App\Observers\ArtworkObserver; +use App\Observers\ArtworkReactionObserver; use App\Services\Upload\Contracts\UploadDraftServiceInterface; use App\Services\Upload\UploadDraftService; use Illuminate\Support\Facades\View; @@ -44,10 +50,14 @@ class AppServiceProvider extends ServiceProvider $this->configureAuthRateLimiters(); $this->configureUploadRateLimiters(); + $this->configureMessagingRateLimiters(); $this->configureMailFailureLogging(); ArtworkAward::observe(ArtworkAwardObserver::class); Artwork::observe(ArtworkObserver::class); + ArtworkFavourite::observe(ArtworkFavouriteObserver::class); + ArtworkComment::observe(ArtworkCommentObserver::class); + ArtworkReaction::observe(ArtworkReactionObserver::class); // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { @@ -65,18 +75,23 @@ class AppServiceProvider extends ServiceProvider } try { - // legacy table name fallback handled elsewhere; here we look for user_favorites or favourites - $favCount = DB::table('user_favorites')->where('user_id', $userId)->count(); + $favCount = DB::table('artwork_favourites')->where('user_id', $userId)->count(); } catch (\Throwable $e) { - try { - $favCount = DB::table('favourites')->where('user_id', $userId)->count(); - } catch (\Throwable $e) { - $favCount = 0; - } + $favCount = 0; } try { - $msgCount = DB::table('messages')->where('reciever_id', $userId)->whereNull('read_at')->count(); + $msgCount = (int) DB::table('conversation_participants as cp') + ->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id') + ->where('cp.user_id', $userId) + ->whereNull('cp.left_at') + ->whereNull('m.deleted_at') + ->where('m.sender_id', '!=', $userId) + ->where(function ($q) { + $q->whereNull('cp.last_read_at') + ->orWhereColumn('m.created_at', '>', 'cp.last_read_at'); + }) + ->count(); } catch (\Throwable $e) { $msgCount = 0; } @@ -179,4 +194,25 @@ class AppServiceProvider extends ServiceProvider return $limits; } + + private function configureMessagingRateLimiters(): void + { + RateLimiter::for('messages-send', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + + return [ + Limit::perMinute(20)->by('messages:user:' . $userId), + Limit::perMinute(40)->by('messages:ip:' . $request->ip()), + ]; + }); + + RateLimiter::for('messages-react', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + + return [ + Limit::perMinute(60)->by('messages:react:user:' . $userId), + Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()), + ]; + }); + } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 6c9cf494..2600e01a 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -5,8 +5,10 @@ use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvid use Illuminate\Support\Facades\Gate; use App\Models\Artwork; use App\Models\ArtworkAward; +use App\Models\ArtworkComment; use App\Policies\ArtworkPolicy; use App\Policies\ArtworkAwardPolicy; +use App\Policies\ArtworkCommentPolicy; class AuthServiceProvider extends ServiceProvider { @@ -16,8 +18,9 @@ class AuthServiceProvider extends ServiceProvider * @var array */ protected $policies = [ - Artwork::class => ArtworkPolicy::class, - ArtworkAward::class => ArtworkAwardPolicy::class, + Artwork::class => ArtworkPolicy::class, + ArtworkAward::class => ArtworkAwardPolicy::class, + ArtworkComment::class => ArtworkCommentPolicy::class, ]; /** diff --git a/app/Services/ArtworkAwardService.php b/app/Services/ArtworkAwardService.php index 28d4cf3d..e968ef46 100644 --- a/app/Services/ArtworkAwardService.php +++ b/app/Services/ArtworkAwardService.php @@ -69,15 +69,21 @@ class ArtworkAwardService /** * Remove an award for a user/artwork pair. + * Uses model-level delete so the ArtworkAwardObserver fires. */ public function removeAward(Artwork $artwork, User $user): void { - ArtworkAward::where('artwork_id', $artwork->id) + $award = ArtworkAward::where('artwork_id', $artwork->id) ->where('user_id', $user->id) - ->delete(); + ->first(); - $this->recalcStats($artwork->id); - $this->syncToSearch($artwork); + if ($award) { + $award->delete(); // fires ArtworkAwardObserver::deleted + } else { + // Nothing to remove, but still sync stats to be safe. + $this->recalcStats($artwork->id); + $this->syncToSearch($artwork); + } } /** diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php index b51d8dd6..ff5aeedc 100644 --- a/app/Services/ArtworkSearchService.php +++ b/app/Services/ArtworkSearchService.php @@ -172,6 +172,73 @@ final class ArtworkSearchService }); } + // ── Discover section helpers ─────────────────────────────────────────────── + + /** + * Trending: most viewed artworks, weighted toward recent uploads. + * Uses views:desc + recency via created_at:desc as tiebreaker. + */ + public function discoverTrending(int $perPage = 24): LengthAwarePaginator + { + $page = (int) request()->get('page', 1); + return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER, + 'sort' => ['views:desc', 'created_at:desc'], + ]) + ->paginate($perPage); + }); + } + + /** + * Fresh: newest uploads first. + */ + public function discoverFresh(int $perPage = 24): LengthAwarePaginator + { + $page = (int) request()->get('page', 1); + return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER, + 'sort' => ['created_at:desc'], + ]) + ->paginate($perPage); + }); + } + + /** + * Top rated: highest number of favourites/likes. + */ + public function discoverTopRated(int $perPage = 24): LengthAwarePaginator + { + $page = (int) request()->get('page', 1); + return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER, + 'sort' => ['likes:desc', 'views:desc'], + ]) + ->paginate($perPage); + }); + } + + /** + * Most downloaded: highest download count. + */ + public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator + { + $page = (int) request()->get('page', 1); + return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) { + return Artwork::search('') + ->options([ + 'filter' => self::BASE_FILTER, + 'sort' => ['downloads:desc', 'views:desc'], + ]) + ->paginate($perPage); + }); + } + // ------------------------------------------------------------------------- private function parseSort(string $sort): array diff --git a/app/Services/ArtworkStatsService.php b/app/Services/ArtworkStatsService.php index ee391b64..ec815f9d 100644 --- a/app/Services/ArtworkStatsService.php +++ b/app/Services/ArtworkStatsService.php @@ -1,9 +1,13 @@ redisAvailable()) { - $this->pushDelta($artworkId, 'views', $by); - return; - $this->applyDelta($artworkId, ['views' => $by]); - } + if ($defer && $this->redisAvailable()) { + $this->pushDelta($artworkId, 'views', $by); + return; + } + $this->applyDelta($artworkId, ['views' => $by]); + } - /** - * Increment downloads for an artwork. - */ - public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void - { - if ($defer && $this->redisAvailable()) { - $this->pushDelta($artworkId, 'downloads', $by); - return; + /** + * Increment downloads for an artwork. + */ + public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void + { + if ($defer && $this->redisAvailable()) { + $this->pushDelta($artworkId, 'downloads', $by); + return; + } + $this->applyDelta($artworkId, ['downloads' => $by]); + } - /** - * Increment views using an Artwork model. Preferred API-first signature. - */ - public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void - { - $this->incrementViews((int) $artwork->id, $by, $defer); - } - } - $this->applyDelta($artworkId, ['downloads' => $by]); - } + /** + * Increment views using an Artwork model. + */ + public function incrementViewsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void + { + $this->incrementViews((int) $artwork->id, $by, $defer); + } - /** - * Apply a set of deltas to the artwork_stats row inside a transaction. - * This method is safe to call from jobs or synchronously. - * - * @param int $artworkId - * @param array $deltas - */ + /** + * Increment downloads using an Artwork model. + */ + public function incrementDownloadsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void + { + $this->incrementDownloads((int) $artwork->id, $by, $defer); + } + + /** + * Apply a set of deltas to the artwork_stats row inside a transaction. + * After updating artwork-level stats, forwards view/download counts to + * UserStatsService so creator-level counters stay current. + * + * @param int $artworkId + * @param array $deltas + */ public function applyDelta(int $artworkId, array $deltas): void { - try { - DB::transaction(function () use ($artworkId, $deltas) { - // Ensure a stats row exists. Insert default zeros if missing. - DB::table('artwork_stats')->insertOrIgnore([ - 'artwork_id' => $artworkId, + try { + DB::transaction(function () use ($artworkId, $deltas) { + // Ensure a stats row exists — insert default zeros if missing. + DB::table('artwork_stats')->insertOrIgnore([ + 'artwork_id' => $artworkId, + 'views' => 0, + 'downloads' => 0, + 'favorites' => 0, + 'rating_avg' => 0, + 'rating_count' => 0, + ]); - /** - * Increment downloads using an Artwork model. Preferred API-first signature. - */ - public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void - { - $this->incrementDownloads((int) $artwork->id, $by, $defer); - } - 'views' => 0, - 'downloads' => 0, - 'favorites' => 0, - 'rating_avg' => 0, - 'rating_count' => 0, - ]); + foreach ($deltas as $column => $value) { + // Only allow known columns to avoid SQL injection. + if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) { + continue; + } - foreach ($deltas as $column => $value) { - // Only allow known columns to avoid SQL injection - if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) { - continue; - } + DB::table('artwork_stats') + ->where('artwork_id', $artworkId) + ->increment($column, (int) $value); + } + }); - DB::table('artwork_stats') - ->where('artwork_id', $artworkId) - ->increment($column, (int) $value); - } - }); - } catch (Throwable $e) { - Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]); - } - } + // Forward creator-level counters outside the transaction. + $this->forwardCreatorStats($artworkId, $deltas); + } catch (Throwable $e) { + Log::error('Failed to apply artwork stats delta', [ + 'artwork_id' => $artworkId, + 'deltas' => $deltas, + 'error' => $e->getMessage(), + ]); + } + } - /** - * Push a delta to Redis queue for async processing. - */ - protected function pushDelta(int $artworkId, string $field, int $value): void - { - $payload = json_encode([ - 'artwork_id' => $artworkId, - 'field' => $field, - 'value' => $value, - 'ts' => time(), - ]); + /** + * After applying artwork-level deltas, forward relevant totals to the + * creator's user_statistics row via UserStatsService. + * Views skip Meilisearch reindex (high frequency — covered by recompute). + * + * @param int $artworkId + * @param array $deltas + */ + protected function forwardCreatorStats(int $artworkId, array $deltas): void + { + $viewDelta = (int) ($deltas['views'] ?? 0); + $downloadDelta = (int) ($deltas['downloads'] ?? 0); - try { - Redis::rpush($this->redisKey, $payload); - } catch (Throwable $e) { - // If Redis is unavailable, fallback to immediate apply to avoid data loss - Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]); - $this->applyDelta($artworkId, [$field => $value]); - } - } + if ($viewDelta <= 0 && $downloadDelta <= 0) { + return; + } - /** - * Drain and apply queued deltas from Redis. Returns number processed. - * Designed to be invoked by a queued job or artisan command. - */ + try { + $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id'); + if (! $creatorId) { + return; + } + + /** @var UserStatsService $svc */ + $svc = app(UserStatsService::class); + + if ($viewDelta > 0) { + // High-frequency: increment counter but skip Meilisearch reindex. + $svc->incrementArtworkViewsReceived($creatorId, $viewDelta); + } + + if ($downloadDelta > 0) { + $svc->incrementDownloadsReceived($creatorId, $downloadDelta); + } + } catch (Throwable $e) { + Log::warning('Failed to forward creator stats from artwork delta', [ + 'artwork_id' => $artworkId, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Push a delta to Redis queue for async processing. + */ + protected function pushDelta(int $artworkId, string $field, int $value): void + { + $payload = json_encode([ + 'artwork_id' => $artworkId, + 'field' => $field, + 'value' => $value, + 'ts' => time(), + ]); + + try { + Redis::rpush($this->redisKey, $payload); + } catch (Throwable $e) { + // If Redis is unavailable, fall back to immediate apply to avoid data loss. + Log::warning('Redis unavailable for artwork stats; applying immediately', [ + 'error' => $e->getMessage(), + ]); + $this->applyDelta($artworkId, [$field => $value]); + } + } + + /** + * Drain and apply queued deltas from Redis. Returns number processed. + * Designed to be invoked by a queued job or artisan command. + */ public function processPendingFromRedis(int $max = 1000): int { - if (! $this->redisAvailable()) { - return 0; - } - $processed = 0; + if (! $this->redisAvailable()) { + return 0; + } - try { - while ($processed < $max) { - $item = Redis::lpop($this->redisKey); - if (! $item) { - break; - } + $processed = 0; - $decoded = json_decode($item, true); - if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) { - continue; - $this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]); - $processed++; - } - } catch (Throwable $e) { - Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]); - } + try { + while ($processed < $max) { + $item = Redis::lpop($this->redisKey); + if (! $item) { + break; + } - return $processed; - } + $decoded = json_decode($item, true); + if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) { + continue; + } - protected function redisAvailable(): bool - { - try { - // Redis facade may throw if not configured - $pong = Redis::connection()->ping(); - return (bool) $pong; - } catch (Throwable $e) { - return false; - } - } + $this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]); + $processed++; + } + } catch (Throwable $e) { + Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]); + } + return $processed; + } + + protected function redisAvailable(): bool + { + try { + $pong = Redis::connection()->ping(); + return (bool) $pong; + } catch (Throwable $e) { + return false; + } + } } - diff --git a/app/Services/ContentSanitizer.php b/app/Services/ContentSanitizer.php new file mode 100644 index 00000000..d3014fe4 --- /dev/null +++ b/app/Services/ContentSanitizer.php @@ -0,0 +1,323 @@ + / / hints from really old legacy content + * 3. Parse subset of Markdown (bold, italic, code, links, line breaks) + * 4. Sanitize the rendered HTML: whitelist-only tags, strip attributes + * 5. Return safe HTML ready for storage or display + */ +class ContentSanitizer +{ + /** Maximum number of emoji allowed before triggering a flood error. */ + public const EMOJI_COUNT_MAX = 50; + + /** + * Maximum ratio of emoji-to-total-characters before content is considered + * an emoji flood (applies only when emoji count > 5 to avoid false positives + * on very short strings like a single reaction comment). + */ + public const EMOJI_DENSITY_MAX = 0.40; + + // HTML tags we allow in the final rendered output + private const ALLOWED_TAGS = [ + 'p', 'br', 'strong', 'em', 'code', 'pre', + 'a', 'ul', 'ol', 'li', 'blockquote', 'del', + ]; + + // Allowed attributes per tag + private const ALLOWED_ATTRS = [ + 'a' => ['href', 'title', 'rel', 'target'], + ]; + + private static ?MarkdownConverter $converter = null; + + // ───────────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────────── + + /** + * Convert raw user input (legacy or new) to sanitized HTML. + * + * @param string|null $raw + * @return string Safe HTML + */ + public static function render(?string $raw): string + { + if ($raw === null || trim($raw) === '') { + return ''; + } + + // 1. Convert legacy HTML fragments to Markdown-friendly text + $text = static::legacyHtmlToMarkdown($raw); + + // 2. Parse Markdown → HTML + $html = static::parseMarkdown($text); + + // 3. Sanitize HTML (strip disallowed tags / attrs) + $html = static::sanitizeHtml($html); + + return $html; + } + + /** + * Strip ALL HTML from input, returning plain text with newlines preserved. + */ + public static function stripToPlain(?string $html): string + { + if ($html === null) { + return ''; + } + + // Convert
and

to line breaks before stripping + $text = preg_replace(['//i', '/<\/p>/i'], "\n", $html); + $text = strip_tags($text ?? ''); + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim($text); + } + + /** + * Validate that a Markdown-lite string does not contain disallowed patterns. + * Returns an array of validation errors (empty = OK). + */ + public static function validate(string $raw): array + { + $errors = []; + + if (mb_strlen($raw) > 10_000) { + $errors[] = 'Content exceeds maximum length of 10,000 characters.'; + } + + // Detect raw HTML tags (we forbid them) + if (preg_match('/<[a-z][^>]*>/i', $raw)) { + $errors[] = 'HTML tags are not allowed. Use Markdown formatting instead.'; + } + + // Count emoji to prevent absolute spam + $emojiCount = static::countEmoji($raw); + if ($emojiCount > self::EMOJI_COUNT_MAX) { + $errors[] = 'Too many emoji. Please limit emoji usage.'; + } + + // Reject emoji-flood content: density guard catches e.g. 15 emoji in a + // 20-char string even when the absolute count is below EMOJI_COUNT_MAX. + if ($emojiCount > 5) { + $totalChars = mb_strlen($raw); + if ($totalChars > 0 && ($emojiCount / $totalChars) > self::EMOJI_DENSITY_MAX) { + $errors[] = 'Content is mostly emoji. Please add some text.'; + } + } + + return $errors; + } + + /** + * Collapse consecutive runs of the same emoji in $text. + * + * Delegates to LegacySmileyMapper::collapseFlood() so the behaviour is + * consistent between new submissions and migrated legacy content. + * + * Example: "🍺 🍺 🍺 🍺 🍺 🍺 🍺" (7×) → "🍺 🍺 🍺 🍺 🍺 ×7" + * + * @param int $maxRun Keep at most this many consecutive identical emoji. + */ + public static function collapseFlood(string $text, int $maxRun = 5): string + { + return LegacySmileyMapper::collapseFlood($text, $maxRun); + } + + // ───────────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Convert legacy HTML-style formatting to Markdown equivalents. + * This runs BEFORE Markdown parsing to handle old content gracefully. + */ + private static function legacyHtmlToMarkdown(string $html): string + { + $replacements = [ + // Bold + '/(.*?)<\/b>/is' => '**$1**', + '/(.*?)<\/strong>/is' => '**$1**', + // Italic + '/(.*?)<\/i>/is' => '*$1*', + '/(.*?)<\/em>/is' => '*$1*', + // Line breaks → actual newlines + '//i' => "\n", + // Paragraphs + '/

(.*?)<\/p>/is' => "$1\n\n", + // Strip remaining tags + '/<[^>]+>/' => '', + ]; + + $result = $html; + foreach ($replacements as $pattern => $replacement) { + $result = preg_replace($pattern, $replacement, $result) ?? $result; + } + + // Decode HTML entities (e.g. & → &) + $result = html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return $result; + } + + /** + * Parse Markdown-lite subset to HTML. + */ + private static function parseMarkdown(string $text): string + { + $converter = static::getConverter(); + $result = $converter->convert($text); + + return (string) $result->getContent(); + } + + /** + * Whitelist-based HTML sanitizer. + * Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes. + */ + private static function sanitizeHtml(string $html): string + { + // Parse with DOMDocument + $doc = new \DOMDocument('1.0', 'UTF-8'); + // Suppress warnings from malformed fragments + libxml_use_internal_errors(true); + $doc->loadHTML( + '' . $html . '', + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD + ); + libxml_clear_errors(); + + static::cleanNode($doc->getElementsByTagName('body')->item(0)); + + // Serialize back, removing the wrapping html/body + $body = $doc->getElementsByTagName('body')->item(0); + $inner = ''; + foreach ($body->childNodes as $child) { + $inner .= $doc->saveHTML($child); + } + + // Fix self-closing etc. + return trim($inner); + } + + /** + * Recursively clean a DOMNode — strip forbidden tags/attributes. + */ + private static function cleanNode(\DOMNode $node): void + { + $toRemove = []; + $toUnwrap = []; + + foreach ($node->childNodes as $child) { + if ($child->nodeType === XML_ELEMENT_NODE) { + $tag = strtolower($child->nodeName); + + if (! in_array($tag, self::ALLOWED_TAGS, true)) { + // Replace element with its text content + $toUnwrap[] = $child; + } else { + // Strip disallowed attributes + $allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? []; + $attrsToRemove = []; + foreach ($child->attributes as $attr) { + if (! in_array($attr->nodeName, $allowedAttrs, true)) { + $attrsToRemove[] = $attr->nodeName; + } + } + foreach ($attrsToRemove as $attrName) { + $child->removeAttribute($attrName); + } + + // Force external links to be safe + if ($tag === 'a') { + $href = $child->getAttribute('href'); + if ($href && ! static::isSafeUrl($href)) { + $toUnwrap[] = $child; + continue; + } + $child->setAttribute('rel', 'noopener noreferrer nofollow'); + $child->setAttribute('target', '_blank'); + } + + // Recurse + static::cleanNode($child); + } + } + } + + // Unwrap forbidden elements (replace with their children) + foreach ($toUnwrap as $el) { + while ($el->firstChild) { + $node->insertBefore($el->firstChild, $el); + } + $node->removeChild($el); + } + } + + /** + * Very conservative URL whitelist. + */ + private static function isSafeUrl(string $url): bool + { + $lower = strtolower(trim($url)); + + // Allow relative paths and anchors + if (str_starts_with($url, '/') || str_starts_with($url, '#')) { + return true; + } + + // Only allow http(s) + return str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://'); + } + + /** + * Count Unicode emoji in a string (basic heuristic). + */ + private static function countEmoji(string $text): int + { + // Match common emoji ranges + preg_match_all( + '/[\x{1F300}-\x{1FAD6}\x{2600}-\x{27BF}\x{FE00}-\x{FEFF}]/u', + $text, + $matches + ); + + return count($matches[0]); + } + + /** + * Lazy-load and cache the Markdown converter. + */ + private static function getConverter(): MarkdownConverter + { + if (static::$converter === null) { + $env = new Environment([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + 'max_nesting_level' => 10, + ]); + $env->addExtension(new CommonMarkCoreExtension()); + $env->addExtension(new AutolinkExtension()); + $env->addExtension(new StrikethroughExtension()); + + static::$converter = new MarkdownConverter($env); + } + + return static::$converter; + } +} diff --git a/app/Services/FollowService.php b/app/Services/FollowService.php new file mode 100644 index 00000000..3a216ab7 --- /dev/null +++ b/app/Services/FollowService.php @@ -0,0 +1,144 @@ +insertOrIgnore([ + 'user_id' => $targetId, + 'follower_id' => $actorId, + 'created_at' => now(), + ]); + + if ($rows === 0) { + // Already following – nothing to do + return; + } + + $inserted = true; + + // Increment following_count for actor, followers_count for target + $this->incrementCounter($actorId, 'following_count'); + $this->incrementCounter($targetId, 'followers_count'); + }); + + return $inserted; + } + + /** + * Unfollow $targetId on behalf of $actorId. + * + * @return bool true if a follow row was removed, false if wasn't following + */ + public function unfollow(int $actorId, int $targetId): bool + { + if ($actorId === $targetId) { + return false; + } + + $deleted = false; + + DB::transaction(function () use ($actorId, $targetId, &$deleted) { + $rows = DB::table('user_followers') + ->where('user_id', $targetId) + ->where('follower_id', $actorId) + ->delete(); + + if ($rows === 0) { + return; + } + + $deleted = true; + + $this->decrementCounter($actorId, 'following_count'); + $this->decrementCounter($targetId, 'followers_count'); + }); + + return $deleted; + } + + /** + * Toggle follow state. Returns the new following state. + */ + public function toggle(int $actorId, int $targetId): bool + { + if ($this->isFollowing($actorId, $targetId)) { + $this->unfollow($actorId, $targetId); + return false; + } + + $this->follow($actorId, $targetId); + return true; + } + + public function isFollowing(int $actorId, int $targetId): bool + { + return DB::table('user_followers') + ->where('user_id', $targetId) + ->where('follower_id', $actorId) + ->exists(); + } + + /** + * Current followers_count for a user (from cached column, not live count). + */ + public function followersCount(int $userId): int + { + return (int) DB::table('user_statistics') + ->where('user_id', $userId) + ->value('followers_count'); + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private function incrementCounter(int $userId, string $column): void + { + DB::table('user_statistics')->updateOrInsert( + ['user_id' => $userId], + [ + $column => DB::raw("COALESCE({$column}, 0) + 1"), + 'updated_at' => now(), + 'created_at' => now(), // ignored on update + ] + ); + } + + private function decrementCounter(int $userId, string $column): void + { + DB::table('user_statistics') + ->where('user_id', $userId) + ->where($column, '>', 0) + ->update([ + $column => DB::raw("{$column} - 1"), + 'updated_at' => now(), + ]); + } +} diff --git a/app/Services/LegacySmileyMapper.php b/app/Services/LegacySmileyMapper.php new file mode 100644 index 00000000..cdbf7c10 --- /dev/null +++ b/app/Services/LegacySmileyMapper.php @@ -0,0 +1,167 @@ + '🍺', + ':clap' => '👏', + ':coffee' => '☕', + ':cry' => '😢', + ':lol' => '😂', + ':love' => '❤️', + ':HB' => '🎂', + ':wow' => '😮', + // Extended legacy codes + ':smile' => '😊', + ':grin' => '😁', + ':wink' => '😉', + ':tongue' => '😛', + ':cool' => '😎', + ':angry' => '😠', + ':sad' => '😞', + ':laugh' => '😆', + ':hug' => '🤗', + ':thumb' => '👍', + ':thumbs' => '👍', + ':thumbsup' => '👍', + ':fire' => '🔥', + ':star' => '⭐', + ':heart' => '❤️', + ':broken' => '💔', + ':music' => '🎵', + ':note' => '🎶', + ':art' => '🎨', + ':camera' => '📷', + ':gift' => '🎁', + ':cake' => '🎂', + ':wave' => '👋', + ':ok' => '👌', + ':pray' => '🙏', + ':think' => '🤔', + ':eyes' => '👀', + ':rainbow' => '🌈', + ':sun' => '☀️', + ':moon' => '🌙', + ':party' => '🎉', + ':bomb' => '💣', + ':skull' => '💀', + ':alien' => '👽', + ':robot' => '🤖', + ':poop' => '💩', + ':money' => '💰', + ':bulb' => '💡', + ':check' => '✅', + ':x' => '❌', + ':warning' => '⚠️', + ':question' => '❓', + ':exclamation' => '❗', + ':100' => '💯', + ]; + + /** + * Convert all legacy smiley codes in $text to Unicode emoji. + * Only replaces codes that are surrounded by whitespace or start/end of string. + * + * @return string + */ + public static function convert(string $text): string + { + if (empty($text)) { + return $text; + } + + foreach (static::$map as $code => $emoji) { + // Use word-boundary-style: the code must be followed by whitespace, + // end of string, or punctuation — not part of a word. + $escaped = preg_quote($code, '/'); + $text = preg_replace( + '/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um', + $emoji, + $text + ); + } + + return $text; + } + + /** + * Returns all codes that are present in the given text (for reporting). + * + * @return string[] + */ + public static function detect(string $text): array + { + $found = []; + foreach (array_keys(static::$map) as $code) { + $escaped = preg_quote($code, '/'); + if (preg_match('/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um', $text)) { + $found[] = $code; + } + } + return $found; + } + + /** + * Collapse consecutive runs of the same emoji that exceed $maxRun repetitions. + * + * Transforms e.g. "🍺 🍺 🍺 🍺 🍺 🍺 🍺 🍺" (8×) → "🍺 🍺 🍺 🍺 🍺 ×8" + * so that spam/flood content is stored compactly and rendered readably. + * + * Both whitespace-separated ("🍺 🍺 🍺") and run-together ("🍺🍺🍺") forms + * are collapsed. Only emoji from the common Unicode blocks are affected; + * regular text is never touched. + * + * @param int $maxRun Maximum number of identical emoji to keep (default 5). + */ + public static function collapseFlood(string $text, int $maxRun = 5): string + { + if (empty($text)) { + return $text; + } + + $limit = max(1, $maxRun); + + // Match one emoji "unit" (codepoint from common ranges + optional variation + // selector U+FE0E / U+FE0F), followed by $limit or more repetitions of + // (optional horizontal whitespace + the same unit). + // The \1 backreference works byte-for-byte in UTF-8, so it correctly + // matches the same multi-byte sequence each time. + $pattern = '/([\x{1F000}-\x{1FFFF}\x{2600}-\x{27EF}][\x{FE0E}\x{FE0F}]?)' + . '([ \t]*\1){' . $limit . ',}/u'; + + return preg_replace_callback( + $pattern, + static function (array $m) use ($limit): string { + $unit = $m[1]; + // substr_count is byte-safe and correct for multi-byte sequences. + $count = substr_count($m[0], $unit); + return str_repeat($unit . ' ', $limit - 1) . $unit . ' ×' . $count; + }, + $text + ) ?? $text; + } + + /** + * Get the full mapping array. + * + * @return array + */ + public static function getMap(): array + { + return static::$map; + } +} diff --git a/app/Services/Messaging/MessageNotificationService.php b/app/Services/Messaging/MessageNotificationService.php new file mode 100644 index 00000000..61ba75e7 --- /dev/null +++ b/app/Services/Messaging/MessageNotificationService.php @@ -0,0 +1,68 @@ +hasTable('notifications')) { + return; + } + + $recipientIds = ConversationParticipant::query() + ->where('conversation_id', $conversation->id) + ->whereNull('left_at') + ->where('user_id', '!=', $sender->id) + ->where('is_muted', false) + ->where('is_archived', false) + ->pluck('user_id') + ->all(); + + if (empty($recipientIds)) { + return; + } + + $recipientRows = User::query() + ->whereIn('id', $recipientIds) + ->get() + ->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender)) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + + if (empty($recipientRows)) { + return; + } + + $preview = Str::limit((string) $message->body, 120, '…'); + $now = now(); + + $rows = array_map(static fn (int $recipientId) => [ + 'user_id' => $recipientId, + 'type' => 'message', + 'data' => json_encode([ + 'conversation_id' => $conversation->id, + 'sender_id' => $sender->id, + 'sender_name' => $sender->username, + 'preview' => $preview, + 'message_id' => $message->id, + ], JSON_UNESCAPED_UNICODE), + 'read_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], $recipientRows); + + DB::table('notifications')->insert($rows); + } +} diff --git a/app/Services/Messaging/MessageSearchIndexer.php b/app/Services/Messaging/MessageSearchIndexer.php new file mode 100644 index 00000000..3c0f336d --- /dev/null +++ b/app/Services/Messaging/MessageSearchIndexer.php @@ -0,0 +1,50 @@ +id); + } + + public function updateMessage(Message $message): void + { + IndexMessageJob::dispatch($message->id); + } + + public function deleteMessage(Message $message): void + { + DeleteMessageFromIndexJob::dispatch($message->id); + } + + public function rebuildConversation(int $conversationId): void + { + Message::query() + ->where('conversation_id', $conversationId) + ->whereNull('deleted_at') + ->select('id') + ->chunkById(200, function ($messages): void { + foreach ($messages as $message) { + IndexMessageJob::dispatch((int) $message->id); + } + }); + } + + public function rebuildAll(): void + { + Message::query() + ->whereNull('deleted_at') + ->select('id') + ->chunkById(500, function ($messages): void { + foreach ($messages as $message) { + IndexMessageJob::dispatch((int) $message->id); + } + }); + } +} diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php new file mode 100644 index 00000000..f8571659 --- /dev/null +++ b/app/Services/UserStatsService.php @@ -0,0 +1,290 @@ + 0). + * - ensureRow() upserts the row before any counter touch. + * - recomputeUser() rebuilds all columns from authoritative tables. + */ +final class UserStatsService +{ + // ─── Row management ────────────────────────────────────────────────────── + + /** + * Guarantee a user_statistics row exists for the given user. + * Safe to call before every increment. + */ + public function ensureRow(int $userId): void + { + DB::table('user_statistics')->insertOrIgnore([ + 'user_id' => $userId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // ─── Increment helpers ──────────────────────────────────────────────────── + + public function incrementUploads(int $userId, int $by = 1): void + { + $this->ensureRow($userId); + $this->inc($userId, 'uploads_count', $by); + $this->touchActive($userId); + $this->reindex($userId); + } + + public function decrementUploads(int $userId, int $by = 1): void + { + $this->dec($userId, 'uploads_count', $by); + $this->reindex($userId); + } + + public function incrementDownloadsReceived(int $creatorUserId, int $by = 1): void + { + $this->ensureRow($creatorUserId); + $this->inc($creatorUserId, 'downloads_received_count', $by); + $this->reindex($creatorUserId); + } + + public function incrementArtworkViewsReceived(int $creatorUserId, int $by = 1): void + { + $this->ensureRow($creatorUserId); + $this->inc($creatorUserId, 'artwork_views_received_count', $by); + // Views are high-frequency – do NOT reindex on every view. + } + + public function incrementAwardsReceived(int $creatorUserId, int $by = 1): void + { + $this->ensureRow($creatorUserId); + $this->inc($creatorUserId, 'awards_received_count', $by); + $this->reindex($creatorUserId); + } + + public function decrementAwardsReceived(int $creatorUserId, int $by = 1): void + { + $this->dec($creatorUserId, 'awards_received_count', $by); + $this->reindex($creatorUserId); + } + + public function incrementFavoritesReceived(int $creatorUserId, int $by = 1): void + { + $this->ensureRow($creatorUserId); + $this->inc($creatorUserId, 'favorites_received_count', $by); + $this->reindex($creatorUserId); + } + + public function decrementFavoritesReceived(int $creatorUserId, int $by = 1): void + { + $this->dec($creatorUserId, 'favorites_received_count', $by); + $this->reindex($creatorUserId); + } + + public function incrementCommentsReceived(int $creatorUserId, int $by = 1): void + { + $this->ensureRow($creatorUserId); + $this->inc($creatorUserId, 'comments_received_count', $by); + $this->reindex($creatorUserId); + } + + public function decrementCommentsReceived(int $creatorUserId, int $by = 1): void + { + $this->dec($creatorUserId, 'comments_received_count', $by); + $this->reindex($creatorUserId); + } + + public function incrementReactionsReceived(int $creatorUserId, int $by = 1): void + { + $this->ensureRow($creatorUserId); + $this->inc($creatorUserId, 'reactions_received_count', $by); + $this->reindex($creatorUserId); + } + + public function decrementReactionsReceived(int $creatorUserId, int $by = 1): void + { + $this->dec($creatorUserId, 'reactions_received_count', $by); + $this->reindex($creatorUserId); + } + + public function incrementProfileViews(int $userId, int $by = 1): void + { + $this->ensureRow($userId); + $this->inc($userId, 'profile_views_count', $by); + } + + // ─── Timestamp helpers ──────────────────────────────────────────────────── + + public function setLastUploadAt(int $userId, ?Carbon $timestamp = null): void + { + $this->ensureRow($userId); + DB::table('user_statistics') + ->where('user_id', $userId) + ->update([ + 'last_upload_at' => ($timestamp ?? now())->toDateTimeString(), + 'updated_at' => now(), + ]); + } + + public function setLastActiveAt(int $userId, ?Carbon $timestamp = null): void + { + $this->ensureRow($userId); + DB::table('user_statistics') + ->where('user_id', $userId) + ->update([ + 'last_active_at' => ($timestamp ?? now())->toDateTimeString(), + 'updated_at' => now(), + ]); + } + + // ─── Recompute ──────────────────────────────────────────────────────────── + + /** + * Recompute all counters for a single user from authoritative tables. + * Returns the computed values (array) without writing when $dryRun=true. + * + * @return array + */ + public function recomputeUser(int $userId, bool $dryRun = false): array + { + $computed = [ + 'uploads_count' => (int) DB::table('artworks') + ->where('user_id', $userId) + ->whereNull('deleted_at') + ->count(), + + 'downloads_received_count' => (int) DB::table('artwork_downloads as d') + ->join('artworks as a', 'a.id', '=', 'd.artwork_id') + ->where('a.user_id', $userId) + ->whereNull('a.deleted_at') + ->count(), + + 'artwork_views_received_count' => (int) DB::table('artwork_stats as s') + ->join('artworks as a', 'a.id', '=', 's.artwork_id') + ->where('a.user_id', $userId) + ->whereNull('a.deleted_at') + ->sum('s.views'), + + 'awards_received_count' => (int) DB::table('artwork_awards as aw') + ->join('artworks as a', 'a.id', '=', 'aw.artwork_id') + ->where('a.user_id', $userId) + ->whereNull('a.deleted_at') + ->count(), + + 'favorites_received_count' => (int) DB::table('artwork_favourites as f') + ->join('artworks as a', 'a.id', '=', 'f.artwork_id') + ->where('a.user_id', $userId) + ->whereNull('a.deleted_at') + ->count(), + + 'comments_received_count' => (int) DB::table('artwork_comments as c') + ->join('artworks as a', 'a.id', '=', 'c.artwork_id') + ->where('a.user_id', $userId) + ->whereNull('a.deleted_at') + ->whereNull('c.deleted_at') + ->count(), + + 'reactions_received_count' => (int) DB::table('artwork_reactions as r') + ->join('artworks as a', 'a.id', '=', 'r.artwork_id') + ->where('a.user_id', $userId) + ->whereNull('a.deleted_at') + ->count(), + + 'followers_count' => (int) DB::table('user_followers') + ->where('user_id', $userId) + ->count(), + + 'following_count' => (int) DB::table('user_followers') + ->where('follower_id', $userId) + ->count(), + + 'last_upload_at' => DB::table('artworks') + ->where('user_id', $userId) + ->whereNull('deleted_at') + ->max('created_at'), + ]; + + if (! $dryRun) { + $this->ensureRow($userId); + + DB::table('user_statistics') + ->where('user_id', $userId) + ->update(array_merge($computed, ['updated_at' => now()])); + + $this->reindex($userId); + } + + return $computed; + } + + /** + * Recompute stats for all users in chunks. + * + * @param int $chunk Users per chunk. + */ + public function recomputeAll(int $chunk = 1000): void + { + DB::table('users') + ->whereNull('deleted_at') + ->orderBy('id') + ->chunk($chunk, function ($users) { + foreach ($users as $user) { + $this->recomputeUser($user->id); + } + }); + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private function inc(int $userId, string $column, int $by = 1): void + { + DB::table('user_statistics') + ->where('user_id', $userId) + ->update([ + $column => DB::raw("MAX(0, COALESCE({$column}, 0) + {$by})"), + 'updated_at' => now(), + ]); + } + + private function dec(int $userId, string $column, int $by = 1): void + { + DB::table('user_statistics') + ->where('user_id', $userId) + ->where($column, '>', 0) + ->update([ + $column => DB::raw("MAX(0, COALESCE({$column}, 0) - {$by})"), + 'updated_at' => now(), + ]); + } + + private function touchActive(int $userId): void + { + DB::table('user_statistics') + ->where('user_id', $userId) + ->update([ + 'last_active_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * Queue a Meilisearch reindex for the user. + * Uses IndexUserJob to avoid blocking the request. + */ + private function reindex(int $userId): void + { + IndexUserJob::dispatch($userId); + } +} diff --git a/composer.json b/composer.json index ac00bae8..73a471aa 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "laravel/framework": "^12.0", "laravel/scout": "^10.24", "laravel/tinker": "^2.10.1", + "league/commonmark": "^2.8", "meilisearch/meilisearch-php": "^1.16" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 06a14f38..711ebf4c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d725824144ac43bf1938e16a5653dcf4", + "content-hash": "dcc955601c6f66f01bb520614508ed66", "packages": [ { "name": "brick/math", diff --git a/config/messaging.php b/config/messaging.php new file mode 100644 index 00000000..4f7e262f --- /dev/null +++ b/config/messaging.php @@ -0,0 +1,28 @@ + (bool) env('MESSAGING_REALTIME', false), + + 'typing' => [ + 'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8), + 'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'), + ], + + 'search' => [ + 'index' => env('MESSAGING_MEILI_INDEX', 'messages'), + 'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20), + ], + + 'reactions' => [ + 'allowed' => ['👍', '❤️', '🔥', '😂', '👏', '😮'], + ], + + 'attachments' => [ + 'disk' => env('MESSAGING_ATTACHMENTS_DISK', 'local'), + 'max_files' => (int) env('MESSAGING_ATTACHMENTS_MAX_FILES', 5), + 'max_image_kb' => (int) env('MESSAGING_ATTACHMENTS_MAX_IMAGE_KB', 10240), + 'max_file_kb' => (int) env('MESSAGING_ATTACHMENTS_MAX_FILE_KB', 25600), + 'allowed_image_mimes' => ['image/jpeg', 'image/png', 'image/webp'], + 'allowed_file_mimes' => ['application/pdf', 'application/zip', 'application/x-zip-compressed'], + ], +]; diff --git a/config/scout.php b/config/scout.php index c401c39d..c8091e16 100644 --- a/config/scout.php +++ b/config/scout.php @@ -123,6 +123,21 @@ return [ ], ], ], + + env('SCOUT_PREFIX', env('MEILI_PREFIX', '')) . 'messages' => [ + 'searchableAttributes' => [ + 'body_text', + 'sender_username', + ], + 'filterableAttributes' => [ + 'conversation_id', + 'sender_id', + 'has_attachments', + ], + 'sortableAttributes' => [ + 'created_at', + ], + ], ], ], diff --git a/database/factories/ArtworkCommentFactory.php b/database/factories/ArtworkCommentFactory.php new file mode 100644 index 00000000..b13589cf --- /dev/null +++ b/database/factories/ArtworkCommentFactory.php @@ -0,0 +1,32 @@ +faker->sentence(12); + + return [ + 'artwork_id' => Artwork::factory(), + 'user_id' => User::factory(), + 'content' => $raw, + 'raw_content' => $raw, + 'rendered_content' => '

' . e($raw) . '

', + 'is_approved' => true, + ]; + } + + public function unapproved(): static + { + return $this->state(['is_approved' => false]); + } +} diff --git a/database/migrations/2026_02_26_000001_add_content_columns_to_artwork_comments_table.php b/database/migrations/2026_02_26_000001_add_content_columns_to_artwork_comments_table.php new file mode 100644 index 00000000..8be9a369 --- /dev/null +++ b/database/migrations/2026_02_26_000001_add_content_columns_to_artwork_comments_table.php @@ -0,0 +1,25 @@ +mediumText('raw_content')->nullable()->after('content'); + $table->mediumText('rendered_content')->nullable()->after('raw_content'); + }); + } + + public function down(): void + { + Schema::table('artwork_comments', function (Blueprint $table) { + $table->dropColumn(['raw_content', 'rendered_content']); + }); + } +}; diff --git a/database/migrations/2026_02_26_000002_create_reactions_tables.php b/database/migrations/2026_02_26_000002_create_reactions_tables.php new file mode 100644 index 00000000..b0aa60b2 --- /dev/null +++ b/database/migrations/2026_02_26_000002_create_reactions_tables.php @@ -0,0 +1,53 @@ +id(); + $table->unsignedBigInteger('artwork_id'); + $table->unsignedBigInteger('user_id'); + // slug: thumbs_up | heart | fire | laugh | clap | wow + $table->string('reaction', 20); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['artwork_id', 'user_id', 'reaction'], 'artwork_reactions_unique'); + $table->index('artwork_id'); + $table->index('user_id'); + + $table->foreign('artwork_id')->references('id')->on('artworks')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + + Schema::create('comment_reactions', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('comment_id'); + $table->unsignedBigInteger('user_id'); + // slug: thumbs_up | heart | fire | laugh | clap | wow + $table->string('reaction', 20); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['comment_id', 'user_id', 'reaction'], 'comment_reactions_unique'); + $table->index('comment_id'); + $table->index('user_id'); + + $table->foreign('comment_id')->references('id')->on('artwork_comments')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('comment_reactions'); + Schema::dropIfExists('artwork_reactions'); + } +}; diff --git a/database/migrations/2026_02_26_000003_widen_content_columns_to_mediumtext.php b/database/migrations/2026_02_26_000003_widen_content_columns_to_mediumtext.php new file mode 100644 index 00000000..e503caaa --- /dev/null +++ b/database/migrations/2026_02_26_000003_widen_content_columns_to_mediumtext.php @@ -0,0 +1,37 @@ +mediumText('content')->nullable()->change(); + }); + + Schema::table('forum_posts', function (Blueprint $table) { + $table->mediumText('content')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('artwork_comments', function (Blueprint $table) { + $table->text('content')->nullable()->change(); + }); + + Schema::table('forum_posts', function (Blueprint $table) { + $table->text('content')->nullable()->change(); + }); + } +}; diff --git a/database/migrations/2026_02_26_000004_create_artwork_favourites_table.php b/database/migrations/2026_02_26_000004_create_artwork_favourites_table.php new file mode 100644 index 00000000..ac8e8e30 --- /dev/null +++ b/database/migrations/2026_02_26_000004_create_artwork_favourites_table.php @@ -0,0 +1,56 @@ +id(); + + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + + $table->foreignId('artwork_id') + ->constrained('artworks') + ->cascadeOnDelete(); + + // Preserve original legacy PK for idempotent re-imports. + // NULL for favourites created natively in the new system. + $table->unsignedInteger('legacy_id')->nullable()->unique(); + + $table->timestamps(); + + // Prevent duplicate favourites + $table->unique(['user_id', 'artwork_id'], 'artwork_favourites_unique_user_artwork'); + + // Fast lookup: "how many favourites does this artwork have?" + $table->index('artwork_id'); + // Fast lookup: "which artworks has this user favourited?" + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('artwork_favourites'); + } +}; diff --git a/database/migrations/2026_02_26_000005_merge_user_favorites_into_artwork_favourites.php b/database/migrations/2026_02_26_000005_merge_user_favorites_into_artwork_favourites.php new file mode 100644 index 00000000..a90ee350 --- /dev/null +++ b/database/migrations/2026_02_26_000005_merge_user_favorites_into_artwork_favourites.php @@ -0,0 +1,53 @@ +orderBy('id')->chunk(500, function ($rows) { + DB::table('artwork_favourites')->insertOrIgnore( + $rows->map(fn ($r) => [ + 'user_id' => $r->user_id, + 'artwork_id' => $r->artwork_id, + 'created_at' => $r->created_at, + 'updated_at' => $r->created_at, + ])->all() + ); + }); + + Schema::drop('user_favorites'); + } + + public function down(): void + { + if (Schema::hasTable('user_favorites')) { + return; + } + + Schema::create('user_favorites', function ($table) { + $table->id(); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('artwork_id'); + $table->timestamp('created_at')->nullable(); + $table->unique(['user_id', 'artwork_id']); + }); + } +}; diff --git a/database/migrations/2026_02_26_100000_add_follow_counters_to_user_statistics.php b/database/migrations/2026_02_26_100000_add_follow_counters_to_user_statistics.php new file mode 100644 index 00000000..5f91269e --- /dev/null +++ b/database/migrations/2026_02_26_100000_add_follow_counters_to_user_statistics.php @@ -0,0 +1,40 @@ +unsignedInteger('followers_count')->default(0)->after('profile_views'); + $table->unsignedInteger('following_count')->default(0)->after('followers_count'); + }); + + // Backfill follow counters using subquery syntax (compatible with MySQL + SQLite). + DB::statement(" + UPDATE user_statistics + SET followers_count = ( + SELECT COUNT(*) FROM user_followers + WHERE user_followers.user_id = user_statistics.user_id + ) + "); + + DB::statement(" + UPDATE user_statistics + SET following_count = ( + SELECT COUNT(*) FROM user_followers + WHERE user_followers.follower_id = user_statistics.user_id + ) + "); + } + + public function down(): void + { + Schema::table('user_statistics', function (Blueprint $table) { + $table->dropColumn(['followers_count', 'following_count']); + }); + } +}; diff --git a/database/migrations/2026_02_26_200000_upgrade_user_statistics_v2.php b/database/migrations/2026_02_26_200000_upgrade_user_statistics_v2.php new file mode 100644 index 00000000..cfe139b3 --- /dev/null +++ b/database/migrations/2026_02_26_200000_upgrade_user_statistics_v2.php @@ -0,0 +1,134 @@ +renameColumn('uploads', 'uploads_count'); + } + if (Schema::hasColumn('user_statistics', 'downloads')) { + $table->renameColumn('downloads', 'downloads_received_count'); + } + if (Schema::hasColumn('user_statistics', 'pageviews')) { + $table->renameColumn('pageviews', 'artwork_views_received_count'); + } + if (Schema::hasColumn('user_statistics', 'awards')) { + $table->renameColumn('awards', 'awards_received_count'); + } + if (Schema::hasColumn('user_statistics', 'profile_views')) { + $table->renameColumn('profile_views', 'profile_views_count'); + } + }); + + // ── 2. Widen to unsignedBigInteger + add new columns ───────────────── + Schema::table('user_statistics', function (Blueprint $table) { + // Widen existing counters + $table->unsignedBigInteger('uploads_count')->default(0)->change(); + $table->unsignedBigInteger('downloads_received_count')->default(0)->change(); + $table->unsignedBigInteger('artwork_views_received_count')->default(0)->change(); + $table->unsignedBigInteger('awards_received_count')->default(0)->change(); + $table->unsignedBigInteger('profile_views_count')->default(0)->change(); + $table->unsignedBigInteger('followers_count')->default(0)->change(); + $table->unsignedBigInteger('following_count')->default(0)->change(); + + // Add new creator-received counters + if (! Schema::hasColumn('user_statistics', 'favorites_received_count')) { + $table->unsignedBigInteger('favorites_received_count')->default(0)->after('awards_received_count'); + } + if (! Schema::hasColumn('user_statistics', 'comments_received_count')) { + $table->unsignedBigInteger('comments_received_count')->default(0)->after('favorites_received_count'); + } + if (! Schema::hasColumn('user_statistics', 'reactions_received_count')) { + $table->unsignedBigInteger('reactions_received_count')->default(0)->after('comments_received_count'); + } + + // Activity timestamps + if (! Schema::hasColumn('user_statistics', 'last_upload_at')) { + $table->timestamp('last_upload_at')->nullable()->after('reactions_received_count'); + } + if (! Schema::hasColumn('user_statistics', 'last_active_at')) { + $table->timestamp('last_active_at')->nullable()->after('last_upload_at'); + } + }); + + // ── 3. Optional: indexes for creator ranking ───────────────────────── + try { + Schema::table('user_statistics', function (Blueprint $table) { + $table->index('awards_received_count', 'idx_us_awards'); + }); + } catch (\Throwable) {} + + try { + Schema::table('user_statistics', function (Blueprint $table) { + $table->index('favorites_received_count', 'idx_us_favorites'); + }); + } catch (\Throwable) {} + } + + public function down(): void + { + // Remove added columns + Schema::table('user_statistics', function (Blueprint $table) { + foreach (['favorites_received_count', 'comments_received_count', 'reactions_received_count', 'last_upload_at', 'last_active_at'] as $col) { + if (Schema::hasColumn('user_statistics', $col)) { + $table->dropColumn($col); + } + } + }); + + // Drop indexes + Schema::table('user_statistics', function (Blueprint $table) { + try { $table->dropIndex('idx_us_awards'); } catch (\Throwable) {} + try { $table->dropIndex('idx_us_favorites'); } catch (\Throwable) {} + }); + + // Rename back + Schema::table('user_statistics', function (Blueprint $table) { + if (Schema::hasColumn('user_statistics', 'uploads_count')) { + $table->renameColumn('uploads_count', 'uploads'); + } + if (Schema::hasColumn('user_statistics', 'downloads_received_count')) { + $table->renameColumn('downloads_received_count', 'downloads'); + } + if (Schema::hasColumn('user_statistics', 'artwork_views_received_count')) { + $table->renameColumn('artwork_views_received_count', 'pageviews'); + } + if (Schema::hasColumn('user_statistics', 'awards_received_count')) { + $table->renameColumn('awards_received_count', 'awards'); + } + if (Schema::hasColumn('user_statistics', 'profile_views_count')) { + $table->renameColumn('profile_views_count', 'profile_views'); + } + }); + } +}; diff --git a/database/migrations/2026_02_26_300000_create_conversations_table.php b/database/migrations/2026_02_26_300000_create_conversations_table.php new file mode 100644 index 00000000..33469540 --- /dev/null +++ b/database/migrations/2026_02_26_300000_create_conversations_table.php @@ -0,0 +1,27 @@ +id(); + $table->enum('type', ['direct', 'group'])->default('direct'); + $table->string('title')->nullable(); + $table->unsignedBigInteger('created_by'); + $table->timestamp('last_message_at')->nullable()->index(); + $table->timestamps(); + + $table->foreign('created_by')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('conversations'); + } +}; diff --git a/database/migrations/2026_02_26_300001_create_conversation_participants_table.php b/database/migrations/2026_02_26_300001_create_conversation_participants_table.php new file mode 100644 index 00000000..0a70962e --- /dev/null +++ b/database/migrations/2026_02_26_300001_create_conversation_participants_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('conversation_id')->constrained()->onDelete('cascade'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->enum('role', ['member', 'admin'])->default('member'); + $table->timestamp('last_read_at')->nullable(); + $table->boolean('is_muted')->default(false); + $table->boolean('is_archived')->default(false); + $table->timestamp('joined_at')->useCurrent(); + $table->timestamp('left_at')->nullable(); + + $table->unique(['conversation_id', 'user_id']); + $table->index('user_id'); + $table->index('conversation_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('conversation_participants'); + } +}; diff --git a/database/migrations/2026_02_26_300002_create_messages_table.php b/database/migrations/2026_02_26_300002_create_messages_table.php new file mode 100644 index 00000000..6ca9c353 --- /dev/null +++ b/database/migrations/2026_02_26_300002_create_messages_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('conversation_id')->constrained()->onDelete('cascade'); + $table->foreignId('sender_id')->references('id')->on('users')->onDelete('cascade'); + $table->mediumText('body'); + $table->timestamp('edited_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['conversation_id', 'created_at']); + $table->index('sender_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('messages'); + } +}; diff --git a/database/migrations/2026_02_26_300003_create_message_reactions_table.php b/database/migrations/2026_02_26_300003_create_message_reactions_table.php new file mode 100644 index 00000000..66bd34f5 --- /dev/null +++ b/database/migrations/2026_02_26_300003_create_message_reactions_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('message_id')->constrained()->onDelete('cascade'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('reaction', 32); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['message_id', 'user_id', 'reaction']); + $table->index('message_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('message_reactions'); + } +}; diff --git a/database/migrations/2026_02_26_300004_add_allow_messages_from_to_users.php b/database/migrations/2026_02_26_300004_add_allow_messages_from_to_users.php new file mode 100644 index 00000000..c4f8041d --- /dev/null +++ b/database/migrations/2026_02_26_300004_add_allow_messages_from_to_users.php @@ -0,0 +1,24 @@ +enum('allow_messages_from', ['everyone', 'followers', 'mutual_followers', 'nobody']) + ->default('everyone') + ->after('role'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('allow_messages_from'); + }); + } +}; diff --git a/database/migrations/2026_02_26_300005_create_notifications_table.php b/database/migrations/2026_02_26_300005_create_notifications_table.php new file mode 100644 index 00000000..bb491013 --- /dev/null +++ b/database/migrations/2026_02_26_300005_create_notifications_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type', 32); + $table->json('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('read_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_02_26_300006_add_pin_columns_to_conversation_participants.php b/database/migrations/2026_02_26_300006_add_pin_columns_to_conversation_participants.php new file mode 100644 index 00000000..fa23901f --- /dev/null +++ b/database/migrations/2026_02_26_300006_add_pin_columns_to_conversation_participants.php @@ -0,0 +1,35 @@ +boolean('is_pinned')->default(false)->after('is_archived'); + } + + if (! Schema::hasColumn('conversation_participants', 'pinned_at')) { + $table->timestamp('pinned_at')->nullable()->after('is_pinned'); + } + + $table->index(['user_id', 'is_pinned']); + }); + } + + public function down(): void + { + Schema::table('conversation_participants', function (Blueprint $table): void { + if (Schema::hasColumn('conversation_participants', 'pinned_at')) { + $table->dropColumn('pinned_at'); + } + if (Schema::hasColumn('conversation_participants', 'is_pinned')) { + $table->dropColumn('is_pinned'); + } + }); + } +}; diff --git a/database/migrations/2026_02_26_300007_create_message_attachments_table.php b/database/migrations/2026_02_26_300007_create_message_attachments_table.php new file mode 100644 index 00000000..5ec5d24b --- /dev/null +++ b/database/migrations/2026_02_26_300007_create_message_attachments_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('message_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['image', 'file']); + $table->string('mime', 191); + $table->unsignedBigInteger('size_bytes'); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->string('sha256', 64)->nullable(); + $table->string('original_name', 255); + $table->string('storage_path', 500); + $table->timestamp('created_at')->useCurrent(); + + $table->index('message_id'); + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('message_attachments'); + } +}; diff --git a/database/migrations/2026_02_26_300008_create_reports_table.php b/database/migrations/2026_02_26_300008_create_reports_table.php new file mode 100644 index 00000000..01263e4e --- /dev/null +++ b/database/migrations/2026_02_26_300008_create_reports_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('reporter_id')->constrained('users')->cascadeOnDelete(); + $table->enum('target_type', ['message', 'conversation', 'user']); + $table->unsignedBigInteger('target_id'); + $table->string('reason', 120); + $table->text('details')->nullable(); + $table->enum('status', ['open', 'reviewing', 'closed'])->default('open'); + $table->timestamps(); + + $table->index(['target_type', 'target_id']); + $table->index('status'); + $table->index('reporter_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('reports'); + } +}; diff --git a/database/migrations/2026_02_26_300009_add_message_reaction_user_index.php b/database/migrations/2026_02_26_300009_add_message_reaction_user_index.php new file mode 100644 index 00000000..62aa8bde --- /dev/null +++ b/database/migrations/2026_02_26_300009_add_message_reaction_user_index.php @@ -0,0 +1,22 @@ +index('user_id'); + }); + } + + public function down(): void + { + Schema::table('message_reactions', function (Blueprint $table): void { + $table->dropIndex(['user_id']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index af85ed7b..606beb2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,11 +5,15 @@ "packages": { "": { "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@inertiajs/core": "^1.0.4", "@inertiajs/react": "^1.0.4", + "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-markdown": "^10.1.0" }, "devDependencies": { "@playwright/test": "^1.40.0", @@ -57,33 +61,6 @@ "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", - "peer": true, - "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", - "peer": true, - "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", @@ -209,6 +186,22 @@ "node": ">=18" } }, + "node_modules/@emoji-mart/data": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", + "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", + "license": "MIT" + }, + "node_modules/@emoji-mart/react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz", + "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==", + "license": "MIT", + "peerDependencies": { + "emoji-mart": "^5.2", + "react": "^16.8 || ^17 || ^18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1739,27 +1732,6 @@ "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", - "peer": true, - "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", @@ -1802,21 +1774,66 @@ "@testing-library/dom": ">=7.21.4" } }, - "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, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "license": "MIT", - "peer": true + "dependencies": { + "@types/ms": "*" + } }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "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", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -2007,17 +2024,6 @@ "dev": true, "license": "MIT" }, - "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", - "peer": true, - "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", @@ -2082,6 +2088,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2222,6 +2238,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2269,6 +2295,46 @@ "node": ">=8" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2342,6 +2408,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2429,7 +2505,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2450,6 +2525,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2482,9 +2570,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -2499,6 +2585,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2513,14 +2612,6 @@ "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", - "peer": true - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2542,6 +2633,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-mart": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", + "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2680,6 +2777,16 @@ "node": ">=6" } }, + "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", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2700,6 +2807,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3000,6 +3113,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3013,6 +3166,16 @@ "node": ">=18" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3061,6 +3224,36 @@ "dev": true, "license": "MIT" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3090,6 +3283,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3123,6 +3326,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3133,6 +3346,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3150,14 +3375,6 @@ "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", - "peer": true - }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", @@ -3507,6 +3724,16 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -3521,17 +3748,6 @@ "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", - "peer": true, - "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", @@ -3551,6 +3767,159 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3561,6 +3930,448 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3638,7 +4449,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3742,6 +4552,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -4029,34 +4864,14 @@ "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, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", "license": "MIT", - "peer": true, - "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", - "peer": true, - "engines": { - "node": ">=10" - }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/proxy-from-env": { @@ -4132,13 +4947,32 @@ "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, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "license": "MIT", - "peer": true + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } }, "node_modules/read-cache": { "version": "1.0.0", @@ -4164,6 +4998,39 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4441,6 +5308,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4470,6 +5347,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4483,6 +5374,24 @@ "node": ">=8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4821,6 +5730,26 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4834,6 +5763,93 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4872,6 +5888,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -6240,6 +7284,16 @@ "engines": { "node": ">=12" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 38bdc235..727e73eb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "playwright:install": "npx playwright install" }, "devDependencies": { + "@playwright/test": "^1.40.0", "@tailwindcss/forms": "^0.5.2", "@tailwindcss/vite": "^4.0.0", "@testing-library/react": "^16.1.0", @@ -27,14 +28,17 @@ "sass": "^1.70.0", "tailwindcss": "^3.1.0", "vite": "^7.0.7", - "vitest": "^2.1.8", - "@playwright/test": "^1.40.0" + "vitest": "^2.1.8" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@inertiajs/core": "^1.0.4", "@inertiajs/react": "^1.0.4", + "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-markdown": "^10.1.0" } } diff --git a/playwright-report/data/3805b34cfd601b3a221f7f1a9b99827131dffbb1.png b/playwright-report/data/3805b34cfd601b3a221f7f1a9b99827131dffbb1.png new file mode 100644 index 00000000..b36a2ff5 Binary files /dev/null and b/playwright-report/data/3805b34cfd601b3a221f7f1a9b99827131dffbb1.png differ diff --git a/playwright-report/data/44eed0617fdda327586a382401abe7fe1b3e62b2.md b/playwright-report/data/44eed0617fdda327586a382401abe7fe1b3e62b2.md new file mode 100644 index 00000000..b6dacef2 --- /dev/null +++ b/playwright-report/data/44eed0617fdda327586a382401abe7fe1b3e62b2.md @@ -0,0 +1,173 @@ +# Page snapshot + +```yaml +- generic [active] [ref=e1]: + - banner [ref=e2]: + - generic [ref=e3]: + - link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]: + - /url: / + - img "Skinbase.org" [ref=e5] + - generic [ref=e6]: Skinbase.org + - navigation "Main navigation" [ref=e7]: + - button "Discover" [ref=e9] [cursor=pointer]: + - text: Discover + - img [ref=e10] + - button "Browse" [ref=e13] [cursor=pointer]: + - text: Browse + - img [ref=e14] + - button "Creators" [ref=e17] [cursor=pointer]: + - text: Creators + - img [ref=e18] + - button "Community" [ref=e21] [cursor=pointer]: + - text: Community + - img [ref=e22] + - generic [ref=e26]: + - button "Open search" [ref=e27] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e30]: Search\u2026 + - generic [ref=e31]: CtrlK + - search: + - generic: + - img + - searchbox "Search" + - generic: + - generic: Esc + - button "Close search": + - img + - link "Upload" [ref=e32] [cursor=pointer]: + - /url: http://skinbase26.test/upload + - img [ref=e33] + - text: Upload + - generic [ref=e35]: + - link "Favourites" [ref=e36] [cursor=pointer]: + - /url: http://skinbase26.test/dashboard/favorites + - img [ref=e37] + - link "Messages" [ref=e39] [cursor=pointer]: + - /url: http://skinbase26.test/messages + - img [ref=e40] + - link "Notifications" [ref=e42] [cursor=pointer]: + - /url: http://skinbase26.test/dashboard/comments + - img [ref=e43] + - button "E2E Owner E2E Owner" [ref=e47] [cursor=pointer]: + - img "E2E Owner" [ref=e48] + - generic [ref=e49]: E2E Owner + - img [ref=e50] + - text:                      + - main [ref=e52]: + - generic [ref=e55]: + - complementary [ref=e56]: + - generic [ref=e57]: + - heading "Messages" [level=1] [ref=e58] + - button "New message" [ref=e59] [cursor=pointer]: + - img [ref=e60] + - searchbox "Search all messages…" [ref=e63] + - generic [ref=e64]: + - searchbox "Search conversations…" [ref=e66] + - list [ref=e67]: + - listitem [ref=e68]: + - button "E e2ep708148630 now Seed latest from owner" [ref=e69] [cursor=pointer]: + - generic [ref=e70]: E + - generic [ref=e71]: + - generic [ref=e72]: + - generic [ref=e74]: e2ep708148630 + - generic [ref=e75]: now + - generic [ref=e77]: Seed latest from owner + - main [ref=e78]: + - generic [ref=e79]: + - generic [ref=e80]: + - paragraph [ref=e82]: e2ep708148630 + - button "Pin" [ref=e83] [cursor=pointer] + - searchbox "Search in this conversation…" [ref=e85] + - generic [ref=e86]: + - generic [ref=e87]: + - separator [ref=e88] + - generic [ref=e89]: Today + - separator [ref=e90] + - generic [ref=e92]: + - generic [ref=e94]: E + - generic [ref=e95]: + - generic [ref=e96]: + - generic [ref=e97]: e2ep708148630 + - generic [ref=e98]: 09:11 PM + - paragraph [ref=e102]: Seed hello + - generic [ref=e104]: + - generic [ref=e106]: E + - generic [ref=e107]: + - generic [ref=e108]: + - generic [ref=e109]: e2eo708148630 + - generic [ref=e110]: 09:11 PM + - paragraph [ref=e114]: Seed latest from owner + - generic [ref=e115]: Seen 4s ago + - generic [ref=e116]: + - button "📎" [ref=e117] [cursor=pointer] + - textbox "Write a message… (Enter to send, Shift+Enter for new line)" [ref=e118] + - button "Send" [disabled] [ref=e119] + - contentinfo [ref=e120]: + - generic [ref=e121]: + - generic [ref=e122]: + - img "Skinbase" [ref=e123] + - generic [ref=e124]: Skinbase + - generic [ref=e125]: + - link "Bug Report" [ref=e126] [cursor=pointer]: + - /url: /bug-report + - link "RSS Feeds" [ref=e127] [cursor=pointer]: + - /url: /rss-feeds + - link "FAQ" [ref=e128] [cursor=pointer]: + - /url: /faq + - link "Rules and Guidelines" [ref=e129] [cursor=pointer]: + - /url: /rules-and-guidelines + - link "Staff" [ref=e130] [cursor=pointer]: + - /url: /staff + - link "Privacy Policy" [ref=e131] [cursor=pointer]: + - /url: /privacy-policy + - generic [ref=e132]: © 2026 Skinbase.org + - generic [ref=e133]: + - generic [ref=e135]: + - generic [ref=e137]: + - generic [ref=e138] [cursor=pointer]: + - generic: Request + - generic [ref=e139] [cursor=pointer]: + - generic: Timeline + - generic [ref=e140] [cursor=pointer]: + - generic: Queries + - generic [ref=e141]: "14" + - generic [ref=e142] [cursor=pointer]: + - generic: Models + - generic [ref=e143]: "5" + - generic [ref=e144] [cursor=pointer]: + - generic: Cache + - generic [ref=e145]: "2" + - generic [ref=e146]: + - generic [ref=e153] [cursor=pointer]: + - generic [ref=e154]: "4" + - generic [ref=e155]: GET /api/messages/4 + - generic [ref=e156] [cursor=pointer]: + - generic: 706ms + - generic [ref=e158] [cursor=pointer]: + - generic: 28MB + - generic [ref=e160] [cursor=pointer]: + - generic: 12.x + - generic [ref=e162]: + - generic [ref=e164]: + - generic: + - list + - generic [ref=e166]: + - list [ref=e167] + - textbox "Search" [ref=e170] + - generic [ref=e171]: + - list + - generic [ref=e173]: + - list + - list [ref=e178] + - generic [ref=e180]: + - generic: + - list + - generic [ref=e182]: + - list [ref=e183] + - textbox "Search" [ref=e186] + - generic [ref=e187]: + - list + - generic [ref=e189]: + - generic: + - list +``` \ No newline at end of file diff --git a/playwright-report/index.html b/playwright-report/index.html index 1e0b9d82..55c66700 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/resources/js/Pages/ArtworkPage.jsx b/resources/js/Pages/ArtworkPage.jsx index 9ca078c4..e6a80ad0 100644 --- a/resources/js/Pages/ArtworkPage.jsx +++ b/resources/js/Pages/ArtworkPage.jsx @@ -10,6 +10,7 @@ import ArtworkAuthor from '../components/artwork/ArtworkAuthor' import ArtworkRelated from '../components/artwork/ArtworkRelated' import ArtworkDescription from '../components/artwork/ArtworkDescription' import ArtworkComments from '../components/artwork/ArtworkComments' +import ArtworkReactions from '../components/artwork/ArtworkReactions' import ArtworkNavigator from '../components/viewer/ArtworkNavigator' import ArtworkViewer from '../components/viewer/ArtworkViewer' @@ -80,7 +81,13 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present - + +