$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+`