From 55e4c5ff0c6c2b8c691db74b054cede0e88c3ae2 Mon Sep 17 00:00:00 2001 From: codey Date: Wed, 3 Dec 2025 13:27:37 -0500 Subject: [PATCH] feat(api): add endpoints for fetching reaction users and searching messages - Implemented GET endpoint to fetch users who reacted with a specific emoji on a message. - Added validation for messageId and emoji parameters. - Enhanced user data retrieval with display names and avatar URLs. - Created a search endpoint for Discord messages with support for content and embed searches. - Included pagination and rate limiting for search results. feat(api): introduce image proxy and link preview endpoints - Developed an image proxy API to securely fetch images from untrusted domains. - Implemented HMAC signing for image URLs to prevent abuse. - Created a link preview API to fetch Open Graph metadata from URLs. - Added support for trusted domains and safe image URL generation. style(pages): create Discord logs page with authentication - Added a new page for displaying archived Discord channel logs. - Integrated authentication check to ensure user access. refactor(utils): enhance API authentication and database connection - Improved API authentication helper to manage user sessions and token refresh. - Established a PostgreSQL database connection utility for Discord logs. --- src/assets/styles/DiscordLogs.css | 2068 ++++++++++++++++++ src/assets/styles/global.css | 4 + src/components/AppLayout.jsx | 6 + src/components/DiscordLogs.jsx | 2584 +++++++++++++++++++++++ src/components/Lighting.jsx | 2 +- src/layouts/Base.astro | 9 +- src/layouts/Nav.astro | 1 + src/middleware.js | 6 +- src/pages/api/discord/cached-image.js | 69 + src/pages/api/discord/channels.js | 89 + src/pages/api/discord/members.js | 307 +++ src/pages/api/discord/messages.js | 667 ++++++ src/pages/api/discord/reaction-users.js | 113 + src/pages/api/discord/search.js | 286 +++ src/pages/api/image-proxy.js | 229 ++ src/pages/api/link-preview.js | 399 ++++ src/pages/discord-logs.astro | 32 + src/utils/apiAuth.js | 117 + src/utils/authFetch.js | 69 +- src/utils/db.js | 18 + 20 files changed, 7066 insertions(+), 9 deletions(-) create mode 100644 src/assets/styles/DiscordLogs.css create mode 100644 src/components/DiscordLogs.jsx create mode 100644 src/pages/api/discord/cached-image.js create mode 100644 src/pages/api/discord/channels.js create mode 100644 src/pages/api/discord/members.js create mode 100644 src/pages/api/discord/messages.js create mode 100644 src/pages/api/discord/reaction-users.js create mode 100644 src/pages/api/discord/search.js create mode 100644 src/pages/api/image-proxy.js create mode 100644 src/pages/api/link-preview.js create mode 100644 src/pages/discord-logs.astro create mode 100644 src/utils/apiAuth.js create mode 100644 src/utils/db.js diff --git a/src/assets/styles/DiscordLogs.css b/src/assets/styles/DiscordLogs.css new file mode 100644 index 0000000..6b6d753 --- /dev/null +++ b/src/assets/styles/DiscordLogs.css @@ -0,0 +1,2068 @@ +/* Discord Logs Archive Styles */ + +.discord-logs-container { + width: 100%; + max-width: 100%; +} + +/* Main layout: sidebar + content side by side */ +.discord-main-layout { + display: flex; + gap: 1.5rem; + min-height: 500px; +} + +/* Content area (messages) */ +.discord-content-area { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* Server/Channel Header */ +.discord-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, rgba(88, 101, 242, 0.12) 0%, rgba(88, 101, 242, 0.04) 100%); + border-radius: 12px; + margin-bottom: 1.5rem; + border: 1px solid rgba(88, 101, 242, 0.2); +} + +[data-theme="dark"] .discord-header { + background: linear-gradient(135deg, rgba(88, 101, 242, 0.15) 0%, rgba(88, 101, 242, 0.05) 100%); + border-color: rgba(88, 101, 242, 0.25); +} + +.discord-server-icon { + width: 48px; + height: 48px; + border-radius: 16px; + object-fit: cover; + flex-shrink: 0; +} + +.discord-header-info { + flex: 1; + min-width: 0; +} + +.discord-server-name { + font-size: 1.125rem; + font-weight: 600; + color: #1a1a1a; + margin: 0; + line-height: 1.3; +} + +[data-theme="dark"] .discord-server-name { + color: #fff; +} + +.discord-channel-name { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + color: #5c5f66; + margin-top: 0.15rem; + flex: 1; + min-width: 0; +} + +[data-theme="dark"] .discord-channel-name { + color: #b5bac1; +} + +.discord-channel-name svg { + width: 18px; + height: 18px; + opacity: 0.7; + flex-shrink: 0; +} + +.discord-topic-divider { + margin: 0 0.5rem; + color: #80848e; + opacity: 0.5; +} + +.discord-channel-topic { + font-size: 0.8rem; + color: #80848e; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; + cursor: pointer; + transition: all 0.2s ease; +} + +.discord-channel-topic:hover { + color: #5c5e66; +} + +.discord-channel-topic.expanded { + white-space: normal; + overflow: visible; + text-overflow: unset; + line-height: 1.4; +} + +[data-theme="dark"] .discord-channel-topic { + color: #949ba4; +} + +[data-theme="dark"] .discord-channel-topic:hover { + color: #b5bac1; +} + +.discord-message-count { + font-size: 0.75rem; + color: #80848e; + background: rgba(0, 0, 0, 0.06); + padding: 0.25rem 0.6rem; + border-radius: 999px; + white-space: nowrap; + flex-shrink: 0; +} + +[data-theme="dark"] .discord-message-count { + background: rgba(255, 255, 255, 0.08); + color: #949ba4; +} + +/* Channel Selector */ +.discord-channel-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.discord-channel-btn { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 0.85rem; + background: rgba(0, 0, 0, 0.04); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + font-size: 0.875rem; + color: #4f545c; + cursor: pointer; + transition: all 0.15s ease; +} + +[data-theme="dark"] .discord-channel-btn { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); + color: #b5bac1; +} + +.discord-channel-btn:hover { + background: rgba(88, 101, 242, 0.1); + border-color: rgba(88, 101, 242, 0.3); + color: #5865f2; +} + +.discord-channel-btn.active { + background: rgba(88, 101, 242, 0.15); + border-color: rgba(88, 101, 242, 0.4); + color: #5865f2; +} + +[data-theme="dark"] .discord-channel-btn:hover, +[data-theme="dark"] .discord-channel-btn.active { + background: rgba(88, 101, 242, 0.2); + border-color: rgba(88, 101, 242, 0.4); + color: #8b9dff; +} + +.discord-channel-btn svg { + width: 16px; + height: 16px; + opacity: 0.7; + flex-shrink: 0; +} + +.discord-channel-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Messages Container */ +.discord-messages { + display: flex; + flex-direction: column; + gap: 0; + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + max-height: calc(100vh - 300px); + min-height: 400px; +} + +/* Message Group (same author within time window) */ +.discord-message-group { + padding: 0.5rem 0; +} + +.discord-message-group:hover { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="dark"] .discord-message-group:hover { + background: rgba(255, 255, 255, 0.02); +} + +/* Single Message */ +.discord-message { + display: flex; + gap: 1rem; + padding: 0.125rem 0.5rem; + position: relative; + transition: background-color 0.3s ease; +} + +/* Message highlight for deep-linking */ +.discord-message-highlight { + background-color: rgba(88, 101, 242, 0.25) !important; + box-shadow: + inset 4px 0 0 0 #5865f2, + 0 0 20px rgba(88, 101, 242, 0.3); + animation: message-highlight-pulse 0.5s ease-in-out 3, message-highlight-fade 3s ease-out 1.5s forwards; + border-radius: 4px; + position: relative; +} + +@keyframes message-highlight-pulse { + 0%, 100% { + background-color: rgba(88, 101, 242, 0.25); + box-shadow: + inset 4px 0 0 0 #5865f2, + 0 0 20px rgba(88, 101, 242, 0.3); + } + 50% { + background-color: rgba(88, 101, 242, 0.4); + box-shadow: + inset 4px 0 0 0 #5865f2, + 0 0 30px rgba(88, 101, 242, 0.5); + } +} + +@keyframes message-highlight-fade { + 0% { + background-color: rgba(88, 101, 242, 0.25); + box-shadow: + inset 4px 0 0 0 #5865f2, + 0 0 20px rgba(88, 101, 242, 0.3); + } + 100% { + background-color: transparent; + box-shadow: + inset 4px 0 0 0 transparent, + 0 0 0 transparent; + } +} + +[data-theme="dark"] .discord-message-highlight { + background-color: rgba(88, 101, 242, 0.3) !important; + box-shadow: + inset 4px 0 0 0 #5865f2, + 0 0 25px rgba(88, 101, 242, 0.4); + animation: message-highlight-pulse-dark 0.5s ease-in-out 3, message-highlight-fade-dark 3s ease-out 1.5s forwards; +} + +@keyframes message-highlight-pulse-dark { + 0%, 100% { + background-color: rgba(88, 101, 242, 0.3); + box-shadow: + inset 4px 0 0 0 #5865f2, + 0 0 25px rgba(88, 101, 242, 0.4); + } + 50% { + background-color: rgba(88, 101, 242, 0.5); + box-shadow: + inset 4px 0 0 0 #7289da, + 0 0 40px rgba(88, 101, 242, 0.6); + } +} + +@keyframes message-highlight-fade-dark { + 0% { + background-color: rgba(88, 101, 242, 0.3); + box-shadow: + inset 4px 0 0 0 #5865f2, + 0 0 25px rgba(88, 101, 242, 0.4); + } + 100% { + background-color: transparent; + box-shadow: + inset 4px 0 0 0 transparent, + 0 0 0 transparent; + } +} + +.discord-message.first-in-group { + padding-top: 0.5rem; + margin-top: 1rem; +} + +.discord-message.first-in-group::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: rgba(0, 0, 0, 0.06); +} + +[data-theme="dark"] .discord-message.first-in-group::before { + background: rgba(255, 255, 255, 0.06); +} + +.discord-message:first-child.first-in-group { + margin-top: 0; +} + +.discord-message:first-child.first-in-group::before { + display: none; +} + +/* Avatar */ +.discord-avatar-wrapper { + width: 40px; + flex-shrink: 0; + padding-top: 2px; +} + +.discord-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +.discord-avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #5865f2 0%, #7289da 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 1rem; +} + +/* Timestamp placeholder for continuation messages */ +.discord-timestamp-gutter { + width: 40px; + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 2px; +} + +.discord-hover-timestamp { + font-size: 0.65rem; + color: #80848e; + opacity: 0; + transition: opacity 0.1s ease; +} + +.discord-message:hover .discord-hover-timestamp { + opacity: 1; +} + +/* Message Content */ +.discord-message-content { + flex: 1; + min-width: 0; +} + +.discord-message-header { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.15rem; +} + +.discord-username { + font-weight: 600; + font-size: 0.9375rem; + color: #1a1a1a; + cursor: pointer; +} + +.discord-username:hover { + text-decoration: underline; +} + +[data-theme="dark"] .discord-username { + color: #f2f3f5; +} + +/* Role colors */ +.discord-username.role-admin { color: #e74c3c; } +.discord-username.role-mod { color: #3498db; } +.discord-username.role-vip { color: #9b59b6; } +.discord-username.role-member { color: #2ecc71; } + +[data-theme="dark"] .discord-username.role-admin { color: #ff6b6b; } +[data-theme="dark"] .discord-username.role-mod { color: #5dade2; } +[data-theme="dark"] .discord-username.role-vip { color: #bb8fce; } +[data-theme="dark"] .discord-username.role-member { color: #58d68d; } + +.discord-bot-tag { + font-size: 0.625rem; + font-weight: 600; + background: #5865f2; + color: #fff; + padding: 0.1rem 0.3rem; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.discord-server-tag { + font-size: 0.625rem; + font-weight: 600; + background: #3ba55c; + color: #fff; + padding: 0.1rem 0.3rem; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +[data-theme="dark"] .discord-server-tag { + background: #2d7d46; +} + +.discord-archive-tag { + font-size: 0.625rem; + font-weight: 600; + background: #9b59b6; + color: #fff; + padding: 0.1rem 0.3rem; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.02em; + cursor: help; +} + +[data-theme="dark"] .discord-archive-tag { + background: #8e44ad; +} + +.discord-timestamp { + font-size: 0.75rem; + color: #80848e; +} + +[data-theme="dark"] .discord-timestamp { + color: #949ba4; +} + +.discord-text { + font-size: 0.9375rem; + line-height: 1.375; + color: #2e3338; + word-wrap: break-word; +} + +[data-theme="dark"] .discord-text { + color: #dcddde; +} + +/* Inline formatting */ +.discord-text code { + background: rgba(0, 0, 0, 0.06); + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 0.85em; +} + +[data-theme="dark"] .discord-text code { + background: rgba(255, 255, 255, 0.08); +} + +.discord-text a { + color: #00aff4; + text-decoration: none; +} + +.discord-text a:hover { + text-decoration: underline; +} + +/* Mentions */ +.discord-mention { + background: rgba(88, 101, 242, 0.15); + color: #5865f2; + padding: 0 0.2rem; + border-radius: 3px; + font-weight: 500; + cursor: pointer; + text-decoration: none; +} + +.discord-mention:hover { + background: rgba(88, 101, 242, 0.25); +} + +[data-theme="dark"] .discord-mention { + background: rgba(88, 101, 242, 0.3); + color: #c9cdfb; +} + +/* Clickable channel links */ +.discord-channel-link { + cursor: pointer; + text-decoration: none; +} + +.discord-channel-link:hover { + text-decoration: underline; +} + +/* Headings (# ## ### in Discord) */ +.discord-heading { + display: block; + font-weight: 700; + margin-top: 0.5rem; + margin-bottom: 0.25rem; + line-height: 1.3; +} + +.discord-heading-1 { + font-size: 1.5rem; +} + +.discord-heading-2 { + font-size: 1.25rem; +} + +.discord-heading-3 { + font-size: 1.1rem; +} + +/* Subtext / Small text (-# in Discord) */ +.discord-subtext { + display: block; + font-size: 0.75rem; + color: #72767d; + margin-top: 0.25rem; + line-height: 1.4; +} + +[data-theme="dark"] .discord-subtext { + color: #a3a6aa; +} + +/* List items */ +.discord-list-item { + display: block; + padding-left: 1rem; + line-height: 1.5; +} + +/* Timestamps */ +.discord-timestamp { + background: rgba(88, 101, 242, 0.1); + padding: 0.1rem 0.3rem; + border-radius: 3px; + font-size: 0.875em; + cursor: default; +} + +[data-theme="dark"] .discord-timestamp { + background: rgba(88, 101, 242, 0.15); +} + +/* Slash commands */ +.discord-slash-command { + background: rgba(88, 101, 242, 0.15); + color: #5865f2; + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-weight: 500; +} + +/* @everyone and @here */ +.discord-mention-everyone { + background: rgba(250, 168, 26, 0.15); + color: #faa81a; +} + +[data-theme="dark"] .discord-mention-everyone { + background: rgba(250, 168, 26, 0.2); +} + +/* Emoji */ +.discord-emoji { + width: 1.375em; + height: 1.375em; + vertical-align: -0.3em; + object-fit: contain; +} + +.discord-emoji.large { + width: 3rem; + height: 3rem; +} + +/* Stickers */ +.discord-stickers { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.discord-sticker { + display: inline-block; +} + +.discord-sticker-image { + width: 160px; + height: 160px; + object-fit: contain; + border-radius: 4px; +} + +.discord-sticker-lottie { + width: 160px; + height: 160px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(88, 101, 242, 0.1); + border-radius: 8px; + font-size: 0.875rem; + color: var(--text-muted, #72767d); +} + +[data-theme="dark"] .discord-sticker-lottie { + background: rgba(88, 101, 242, 0.15); + color: #b9bbbe; +} + +/* Attachments */ +.discord-attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.discord-attachment-image-wrapper { + max-width: 400px; +} + +.discord-attachment-image { + max-width: 400px; + max-height: 300px; + border-radius: 8px; + cursor: pointer; + transition: opacity 0.15s ease; + display: block; +} + +.discord-attachment-image:hover { + opacity: 0.9; +} + +/* Video attachments */ +.discord-attachment-video-wrapper { + max-width: 500px; +} + +.discord-attachment-video { + max-width: 100%; + max-height: 350px; + border-radius: 8px; + background: #000; +} + +/* Audio attachments */ +.discord-attachment-audio-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 400px; +} + +.discord-attachment-audio { + width: 100%; + height: 32px; + border-radius: 4px; +} + +.discord-attachment-file { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(0, 0, 0, 0.04); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + padding: 0.75rem 1rem; + max-width: 400px; +} + +[data-theme="dark"] .discord-attachment-file { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); +} + +.discord-attachment-icon { + width: 36px; + height: 36px; + color: #5865f2; + flex-shrink: 0; +} + +.discord-attachment-info { + flex: 1; + min-width: 0; +} + +.discord-attachment-name { + font-size: 0.875rem; + color: #00aff4; + text-decoration: none; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.discord-attachment-name:hover { + text-decoration: underline; +} + +.discord-attachment-size { + font-size: 0.75rem; + color: #80848e; + margin-top: 0.1rem; +} + +/* Embeds */ +.discord-embed { + border-left: 4px solid #5865f2; + background: rgba(0, 0, 0, 0.03); + border-radius: 4px; + padding: 0.75rem 1rem; + margin-top: 0.5rem; + max-width: 500px; +} + +[data-theme="dark"] .discord-embed { + background: rgba(0, 0, 0, 0.2); +} + +.discord-embed-author { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.discord-embed-author-icon { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.discord-embed-author-name { + font-size: 0.875rem; + font-weight: 600; + color: #2e3338; +} + +[data-theme="dark"] .discord-embed-author-name { + color: #dcddde; +} + +.discord-embed-title { + font-size: 1rem; + font-weight: 600; + color: #2e3338; + margin-bottom: 0.5rem; +} + +[data-theme="dark"] .discord-embed-title { + color: #f2f3f5; +} + +/* Only style as link when it's an actual anchor */ +a.discord-embed-title { + color: #00aff4; + text-decoration: none; +} + +a.discord-embed-title:hover { + text-decoration: underline; +} + +.discord-embed-description { + font-size: 0.875rem; + color: #4f545c; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; +} + +[data-theme="dark"] .discord-embed-description { + color: #b5bac1; +} + +/* Embed fields */ +.discord-embed-fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + margin-top: 0.5rem; +} + +.discord-embed-field { + grid-column: span 3; +} + +.discord-embed-field.inline { + grid-column: span 1; +} + +.discord-embed-field-name { + font-size: 0.875rem; + font-weight: 600; + color: #2e3338; + margin-bottom: 0.125rem; +} + +[data-theme="dark"] .discord-embed-field-name { + color: #f2f3f5; +} + +.discord-embed-field-value { + font-size: 0.875rem; + color: #4f545c; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; +} + +[data-theme="dark"] .discord-embed-field-value { + color: #b5bac1; +} + +.discord-embed-footer-separator { + color: #80848e; +} + +/* Embed content wrapper for thumbnail layout */ +.discord-embed-content-wrapper { + display: flex; + gap: 1rem; +} + +.discord-embed-content { + flex: 1; + min-width: 0; +} + +.discord-embed-thumbnail { + flex-shrink: 0; + width: 80px; + height: 80px; + border-radius: 4px; + object-fit: cover; +} + +.discord-embed-image { + margin-top: 0.75rem; + max-width: 100%; + max-height: 300px; + border-radius: 4px; + object-fit: contain; +} + +.discord-embed-footer { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + font-size: 0.75rem; + color: #80848e; +} + +.discord-embed-footer-icon { + width: 20px; + height: 20px; + border-radius: 50%; +} + +/* Embed provider (site name) */ +.discord-embed-provider { + font-size: 0.75rem; + font-weight: 500; + color: #80848e; + text-transform: uppercase; + letter-spacing: 0.02em; + margin-bottom: 0.25rem; +} + +[data-theme="dark"] .discord-embed-provider { + color: #949ba4; +} + +/* Loading state for embeds */ +.discord-embed-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 60px; + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="dark"] .discord-embed-loading { + background: rgba(255, 255, 255, 0.02); +} + +/* Video embeds */ +.discord-embed-video { + max-width: 520px; +} + +.discord-embed-video-container { + margin-top: 0.75rem; + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + border-radius: 4px; + overflow: hidden; + background: #000; +} + +.discord-embed-iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +} + +.discord-embed-video-player { + max-width: 100%; + max-height: 350px; + border-radius: 4px; + background: #000; +} + +/* Reactions */ +.discord-reactions { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.35rem; +} + +.discord-reaction { + display: flex; + align-items: center; + gap: 0.35rem; + background: rgba(0, 0, 0, 0.04); + border: 1px solid transparent; + border-radius: 8px; + padding: 0.2rem 0.5rem; + font-size: 0.8rem; + color: #4f545c; + cursor: pointer; + transition: all 0.1s ease; +} + +[data-theme="dark"] .discord-reaction { + background: rgba(255, 255, 255, 0.06); + color: #b5bac1; +} + +.discord-reaction:hover { + border-color: rgba(88, 101, 242, 0.4); + background: rgba(88, 101, 242, 0.1); +} + +.discord-reaction.reacted { + border-color: rgba(88, 101, 242, 0.5); + background: rgba(88, 101, 242, 0.15); + color: #5865f2; +} + +.discord-reaction-emoji { + width: 1rem; + height: 1rem; +} + +/* Reply thread line */ +.discord-reply-context { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0 0.25rem 3.5rem; + font-size: 0.8125rem; + color: #80848e; + position: relative; + margin-left: 0.5rem; +} + +.discord-reply-context::before { + content: ''; + width: 33px; + height: 13px; + border-left: 2px solid #4f545c; + border-top: 2px solid #4f545c; + border-radius: 8px 0 0 0; + position: absolute; + left: 20px; + top: 50%; + transform: translateY(-50%); +} + +[data-theme="dark"] .discord-reply-context::before { + border-color: #5c5f66; +} + +.discord-reply-avatar { + width: 16px; + height: 16px; + border-radius: 50%; +} + +.discord-reply-username { + font-weight: 600; + color: #5c5f66; + cursor: pointer; +} + +.discord-reply-username:hover { + color: #2e3338; +} + +[data-theme="dark"] .discord-reply-username { + color: #b5bac1; +} + +[data-theme="dark"] .discord-reply-username:hover { + color: #fff; +} + +.discord-reply-content { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +/* Search/Filter */ +.discord-search-bar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + position: relative; +} + +.discord-search-input { + flex: 1; + background: rgba(0, 0, 0, 0.04); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + padding: 0.65rem 1rem; + font-size: 0.9rem; + color: #2e3338; + transition: all 0.15s ease; +} + +[data-theme="dark"] .discord-search-input { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.08); + color: #dcddde; +} + +.discord-search-input:focus { + outline: none; + border-color: rgba(88, 101, 242, 0.5); + background: rgba(88, 101, 242, 0.05); +} + +.discord-search-input::placeholder { + color: #80848e; +} + +.discord-search-loading { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); +} + +/* Search info banner */ +.discord-search-info { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background: rgba(88, 101, 242, 0.1); + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.85rem; + color: #5865f2; +} + +[data-theme="dark"] .discord-search-info { + background: rgba(88, 101, 242, 0.15); +} + +.discord-search-clear { + background: none; + border: none; + color: #5865f2; + cursor: pointer; + font-size: 0.85rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: background 0.15s ease; +} + +.discord-search-clear:hover { + background: rgba(88, 101, 242, 0.15); +} + +/* Date divider */ +.discord-date-divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.5rem 0 1rem; +} + +.discord-date-divider::before, +.discord-date-divider::after { + content: ''; + flex: 1; + height: 1px; + background: rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] .discord-date-divider::before, +[data-theme="dark"] .discord-date-divider::after { + background: rgba(255, 255, 255, 0.08); +} + +.discord-date-divider span { + font-size: 0.75rem; + font-weight: 600; + color: #80848e; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* Loading state */ +.discord-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + gap: 1rem; +} + +.discord-loading-text { + font-size: 0.9rem; + color: #80848e; +} + +/* Empty state */ +.discord-empty { + text-align: center; + padding: 3rem; + color: #80848e; +} + +.discord-empty svg { + width: 64px; + height: 64px; + margin-bottom: 1rem; + opacity: 0.5; +} + +.discord-empty-title { + font-size: 1.125rem; + font-weight: 600; + color: #4f545c; + margin-bottom: 0.5rem; +} + +[data-theme="dark"] .discord-empty-title { + color: #b5bac1; +} + +.discord-empty-description { + font-size: 0.875rem; +} + +/* Pagination / Load more */ +.discord-load-more { + display: flex; + justify-content: center; + padding: 1.5rem; +} + +.discord-loading-more { + display: flex; + justify-content: center; + padding: 0.75rem; +} + +.discord-load-more-btn { + background: rgba(88, 101, 242, 0.1); + border: 1px solid rgba(88, 101, 242, 0.3); + color: #5865f2; + padding: 0.6rem 1.5rem; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.discord-load-more-btn:hover { + background: rgba(88, 101, 242, 0.2); + border-color: rgba(88, 101, 242, 0.5); +} + +[data-theme="dark"] .discord-load-more-btn { + background: rgba(88, 101, 242, 0.15); + color: #8b9dff; +} + +[data-theme="dark"] .discord-load-more-btn:hover { + background: rgba(88, 101, 242, 0.25); +} + +/* Code blocks */ +.discord-code-block { + background: #2b2d31; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 4px; + padding: 0.5rem; + margin: 0.25rem 0; + overflow: auto; + max-height: 400px; + overscroll-behavior: contain; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.4; +} + +.discord-code-block code { + background: none; + padding: 0; + color: #b5bac1; + white-space: pre; +} + +[data-theme="light"] .discord-code-block { + background: #f2f3f5; +} + +[data-theme="light"] .discord-code-block code { + color: #2e3338; +} + +/* Blockquotes */ +.discord-blockquote { + border-left: 4px solid #4e5058; + padding-left: 0.75rem; + margin: 0.25rem 0; + max-height: 300px; + overflow-y: auto; + overscroll-behavior: contain; + color: #b5bac1; +} + +[data-theme="light"] .discord-blockquote { + border-left-color: #c4c9ce; + color: #4e5058; +} + +/* Spoilers */ +.discord-spoiler { + background: #1e1f22; + color: transparent; + border-radius: 3px; + padding: 0 0.2rem; + cursor: pointer; + transition: all 0.1s ease; +} + +.discord-spoiler:hover, +.discord-spoiler.revealed { + background: rgba(255, 255, 255, 0.1); + color: inherit; +} + +[data-theme="light"] .discord-spoiler { + background: #b9bbbe; +} + +[data-theme="light"] .discord-spoiler:hover, +[data-theme="light"] .discord-spoiler.revealed { + background: rgba(0, 0, 0, 0.1); +} + +/* Links in text */ +.discord-link { + color: #00aff4; + text-decoration: none; +} + +.discord-link:hover { + text-decoration: underline; +} + +/* System messages (join, boost, etc.) */ +.discord-system-message { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + color: #80848e; + font-size: 0.9rem; +} + +.discord-system-icon { + width: 20px; + height: 20px; + opacity: 0.7; +} + +.discord-system-icon svg { + width: 100%; + height: 100%; +} + +.discord-system-content { + flex: 1; +} + +.discord-system-content .discord-username { + color: #00aff4; + font-weight: 600; +} + +[data-theme="dark"] .discord-system-message { + color: #949ba4; +} + +/* Responsive */ +@media (max-width: 640px) { + .discord-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .discord-message { + gap: 0.75rem; + } + + .discord-avatar-wrapper, + .discord-timestamp-gutter { + width: 32px; + } + + .discord-avatar, + .discord-avatar-placeholder { + width: 32px; + height: 32px; + } + + .discord-attachment-image, + .discord-attachment-image-wrapper { + max-width: 100%; + } + + .discord-attachment-video-wrapper { + max-width: 100%; + } + + .discord-embed { + max-width: 100%; + } + + .discord-embed-video-container { + padding-bottom: 56.25%; + } +} + +/* ============================================================================ + Sidebar with Guild and Channel Selection + ============================================================================ */ + +.discord-sidebar { + display: flex; + flex-direction: column; + gap: 1rem; + width: 220px; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.02); + border-radius: 8px; + padding: 0.75rem; + max-height: 600px; + overflow-y: auto; + overscroll-behavior: contain; +} + +[data-theme="dark"] .discord-sidebar { + background: rgba(255, 255, 255, 0.02); +} + +/* Guild List (server icons) */ +.discord-guild-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] .discord-guild-list { + border-right-color: rgba(255, 255, 255, 0.08); +} + +.discord-guild-btn { + width: 48px; + height: 48px; + border-radius: 24px; + background: rgba(0, 0, 0, 0.06); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + overflow: hidden; +} + +[data-theme="dark"] .discord-guild-btn { + background: rgba(255, 255, 255, 0.06); +} + +.discord-guild-btn:hover { + border-radius: 16px; + background: rgba(88, 101, 242, 0.2); +} + +.discord-guild-btn.active { + border-radius: 16px; + background: #5865f2; +} + +.discord-guild-icon { + width: 100%; + height: 100%; + object-fit: cover; +} + +.discord-guild-initial { + font-size: 1.25rem; + font-weight: 600; + color: #4f545c; +} + +[data-theme="dark"] .discord-guild-initial { + color: #b5bac1; +} + +.discord-guild-btn.active .discord-guild-initial { + color: #fff; +} + +/* Channel List Header */ +.discord-channel-list-header { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #5c5f66; + padding: 0.5rem 0.5rem 0.75rem; +} + +[data-theme="dark"] .discord-channel-list-header { + color: #949ba4; +} + +/* Channel list in sidebar mode */ +.discord-sidebar .discord-channel-list { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + gap: 0.25rem; + margin-bottom: 0; + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; +} + +/* Category headers in sidebar */ +.discord-category-header { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.4rem 0.25rem; + margin-top: 0.5rem; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #6d6f78; +} + +[data-theme="dark"] .discord-category-header { + color: #8e9297; +} + +.discord-category-header svg { + opacity: 0.7; +} + +.discord-sidebar .discord-channel-btn { + width: 100%; + justify-content: flex-start; + padding: 0.4rem 0.6rem; + border-radius: 4px; + border: none; + background: transparent; + overflow: hidden; + white-space: nowrap; + min-width: 0; +} + +.discord-sidebar .discord-channel-btn svg { + flex-shrink: 0; +} + +.discord-sidebar .discord-channel-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.discord-sidebar .discord-channel-btn.has-category { + padding-left: 1rem; +} + +.discord-sidebar .discord-channel-btn:hover { + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="dark"] .discord-sidebar .discord-channel-btn:hover { + background: rgba(255, 255, 255, 0.04); +} + +.discord-sidebar .discord-channel-btn.active { + background: rgba(88, 101, 242, 0.15); + color: #5865f2; +} + +[data-theme="dark"] .discord-sidebar .discord-channel-btn.active { + background: rgba(88, 101, 242, 0.2); + color: #8b9dff; +} + +/* ============================================================================ + Load More Button + ============================================================================ */ + +.discord-load-more { + display: flex; + justify-content: center; + padding: 1.5rem 0; + margin-top: 1rem; +} + +.discord-load-more-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: rgba(88, 101, 242, 0.1); + border: 1px solid rgba(88, 101, 242, 0.3); + border-radius: 8px; + color: #5865f2; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.discord-load-more-btn:hover:not(:disabled) { + background: rgba(88, 101, 242, 0.2); + border-color: rgba(88, 101, 242, 0.5); +} + +.discord-load-more-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +[data-theme="dark"] .discord-load-more-btn { + background: rgba(88, 101, 242, 0.15); + border-color: rgba(88, 101, 242, 0.4); + color: #8b9dff; +} + +[data-theme="dark"] .discord-load-more-btn:hover:not(:disabled) { + background: rgba(88, 101, 242, 0.25); + border-color: rgba(88, 101, 242, 0.6); +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .discord-main-layout { + flex-direction: column; + } + + .discord-sidebar { + width: 100%; + max-height: 250px; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + } + + .discord-guild-list { + flex-direction: row; + flex-wrap: wrap; + border-bottom: none; + border-right: 1px solid rgba(0, 0, 0, 0.08); + padding-bottom: 0; + padding-right: 0.75rem; + } + + [data-theme="dark"] .discord-guild-list { + border-right-color: rgba(255, 255, 255, 0.08); + } + + .discord-sidebar .discord-channel-list { + flex: 1; + flex-direction: row; + flex-wrap: wrap; + max-height: none; + } + + .discord-sidebar .discord-channel-btn { + width: auto; + } + + .discord-messages { + max-height: calc(100vh - 400px); + } +} + +/* Image Modal */ +.discord-image-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 2rem; + cursor: pointer; +} + +.discord-image-modal { + position: relative; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + cursor: default; +} + +.discord-image-modal-img { + max-width: 100%; + max-height: calc(90vh - 4rem); + object-fit: contain; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.discord-image-modal-close { + position: absolute; + top: -2.5rem; + right: -0.5rem; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #fff; + transition: background 0.2s; +} + +.discord-image-modal-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.discord-image-modal-link { + color: #00aff4; + font-size: 0.875rem; + text-decoration: none; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + transition: background 0.2s; +} + +.discord-image-modal-link:hover { + background: rgba(255, 255, 255, 0.2); + text-decoration: none; +} + +/* ============================================ + Reaction Users Popup + ============================================ */ + +.discord-reaction-popup { + background: #fff; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + z-index: 1001; + min-width: 200px; + max-width: 280px; + max-height: 300px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +[data-theme="dark"] .discord-reaction-popup { + background: #2f3136; +} + +.discord-reaction-popup-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + font-weight: 600; +} + +[data-theme="dark"] .discord-reaction-popup-header { + border-bottom-color: rgba(255, 255, 255, 0.1); +} + +.discord-reaction-popup-emoji { + width: 24px; + height: 24px; + font-size: 1.25rem; +} + +.discord-reaction-popup-count { + color: #80848e; + font-size: 0.875rem; + font-weight: 400; +} + +.discord-reaction-popup-users { + padding: 0.5rem; + overflow-y: auto; + max-height: 220px; +} + +.discord-reaction-popup-loading { + display: flex; + justify-content: center; + padding: 1rem; +} + +.discord-reaction-popup-empty { + text-align: center; + padding: 1rem; + color: #80848e; + font-size: 0.875rem; +} + +.discord-reaction-popup-user { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: 4px; +} + +.discord-reaction-popup-user:hover { + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="dark"] .discord-reaction-popup-user:hover { + background: rgba(255, 255, 255, 0.04); +} + +.discord-reaction-popup-avatar { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.discord-reaction-popup-name { + font-size: 0.875rem; + color: #2e3338; +} + +[data-theme="dark"] .discord-reaction-popup-name { + color: #dcddde; +} + +/* ============================================ + Header Actions (copy link, member toggle) + ============================================ */ + +.discord-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.discord-copy-link-btn, +.discord-member-toggle-btn { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.6rem; + background: rgba(0, 0, 0, 0.06); + border: none; + border-radius: 6px; + cursor: pointer; + color: #5c5f66; + transition: all 0.15s ease; +} + +[data-theme="dark"] .discord-copy-link-btn, +[data-theme="dark"] .discord-member-toggle-btn { + background: rgba(255, 255, 255, 0.08); + color: #b5bac1; +} + +.discord-copy-link-btn:hover, +.discord-member-toggle-btn:hover { + background: rgba(88, 101, 242, 0.15); + color: #5865f2; +} + +.discord-copy-link-btn.copied { + background: rgba(35, 165, 90, 0.2); + color: #23a55a; +} + +.discord-member-toggle-btn.active { + background: rgba(88, 101, 242, 0.2); + color: #5865f2; +} + +.discord-member-count { + font-size: 0.7rem; + font-weight: 500; +} + +/* ============================================ + Member List Panel + ============================================ */ + +.discord-member-list { + width: 220px; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.02); + border-radius: 12px; + padding: 0.75rem; + max-height: calc(100vh - 200px); + overflow-y: auto; + border: 1px solid rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] .discord-member-list { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.06); +} + +.discord-member-list-loading { + display: flex; + justify-content: center; + padding: 2rem; +} + +.discord-member-list-empty { + text-align: center; + padding: 2rem 1rem; + color: #80848e; + font-size: 0.85rem; +} + +.discord-member-group { + margin-bottom: 1rem; +} + +.discord-member-group:last-child { + margin-bottom: 0; +} + +.discord-member-group-header { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + padding: 0.5rem 0.5rem 0.25rem; + color: #80848e; +} + +.discord-member-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.5rem; + border-radius: 6px; + cursor: default; + transition: background 0.15s ease; +} + +.discord-member-item:hover { + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="dark"] .discord-member-item:hover { + background: rgba(255, 255, 255, 0.04); +} + +.discord-member-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.discord-member-avatar-placeholder { + width: 28px; + height: 28px; + border-radius: 50%; + background: #5865f2; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.discord-member-name { + font-size: 0.85rem; + font-weight: 500; + color: #2e3338; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-theme="dark"] .discord-member-name { + color: #dbdee1; +} + +.discord-member-item .discord-bot-tag { + font-size: 0.6rem; + padding: 0.1rem 0.25rem; + flex-shrink: 0; +} + +/* ============================================ + Context Menu (right-click on messages) + ============================================ */ + +.discord-context-menu { + position: fixed; + background: #fff; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + z-index: 1002; + min-width: 180px; + padding: 0.375rem; + overflow: hidden; +} + +[data-theme="dark"] .discord-context-menu { + background: #111214; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.discord-context-menu-item { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.5rem 0.625rem; + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + color: #2e3338; + font-size: 0.875rem; + text-align: left; + transition: background 0.1s ease; +} + +[data-theme="dark"] .discord-context-menu-item { + color: #dbdee1; +} + +.discord-context-menu-item:hover { + background: #5865f2; + color: #fff; +} + +.discord-context-menu-item svg { + width: 18px; + height: 18px; + opacity: 0.7; +} + +.discord-context-menu-item:hover svg { + opacity: 1; +} + diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index fd3f500..0ae17a4 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -367,6 +367,10 @@ Custom background-color: oklch(from rgba(255, 255, 255, 2.0) calc(l - 0.02) c h); } +.discord-logs-container { + padding-bottom: 25px; +} + .random-msg { padding-top: 10px; max-width: 100%; diff --git a/src/components/AppLayout.jsx b/src/components/AppLayout.jsx index 8ae881b..e1362ca 100644 --- a/src/components/AppLayout.jsx +++ b/src/components/AppLayout.jsx @@ -14,6 +14,7 @@ const LoginPage = lazy(() => import('./Login.jsx')); const LyricSearch = lazy(() => import('./LyricSearch')); const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx')); const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx')); +const DiscordLogs = lazy(() => import('./DiscordLogs.jsx')); // NOTE: Player is intentionally NOT imported at module initialization. // We create the lazy import inside the component at render-time only when // we are on the main site and the Player island should be rendered. This @@ -98,6 +99,11 @@ export default function Root({ child, user = undefined, ...props }) { )} {child == "Memes" && } + {child == "DiscordLogs" && ( + Loading...}> + + + )} {child == "qs2.MediaRequestForm" && } {child == "qs2.RequestManagement" && } {child == "ReqForm" && } diff --git a/src/components/DiscordLogs.jsx b/src/components/DiscordLogs.jsx new file mode 100644 index 0000000..5b489bd --- /dev/null +++ b/src/components/DiscordLogs.jsx @@ -0,0 +1,2584 @@ +import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { authFetch } from '@/utils/authFetch'; + +// ============================================================================ + +// Image modal context for child components to trigger modal +const ImageModalContext = createContext(null); + +// Trusted domains that can be embedded directly without server-side proxy +const TRUSTED_DOMAINS = new Set([ + 'youtube.com', 'www.youtube.com', 'youtu.be', + 'instagram.com', 'www.instagram.com', + 'twitter.com', 'x.com', 'www.twitter.com', + 'twitch.tv', 'www.twitch.tv', 'clips.twitch.tv', + 'spotify.com', 'open.spotify.com', + 'soundcloud.com', 'www.soundcloud.com', + 'vimeo.com', 'www.vimeo.com', + 'imgur.com', 'i.imgur.com', + 'giphy.com', 'media.giphy.com', + 'tenor.com', 'media.tenor.com', + 'gfycat.com', + 'reddit.com', 'www.reddit.com', 'v.redd.it', 'i.redd.it', + 'github.com', 'gist.github.com', + 'raw.githubusercontent.com', 'avatars.githubusercontent.com', + 'user-images.githubusercontent.com', 'camo.githubusercontent.com', + 'opengraph.githubassets.com', + 'codepen.io', 'codesandbox.io', + 'streamable.com', 'medal.tv', + 'discord.com', 'cdn.discordapp.com', 'media.discordapp.net', + // Common image CDNs + 'picsum.photos', 'images.unsplash.com', 'unsplash.com', + 'pbs.twimg.com', 'abs.twimg.com', + 'img.youtube.com', 'i.ytimg.com', + 'w3schools.com', 'www.w3schools.com', // for demo video +]); + +// Image extensions +const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i; +// Video extensions +const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|avi|mkv|m4v)(\?.*)?$/i; +// Audio extensions +const AUDIO_EXTENSIONS = /\.(mp3|wav|ogg|flac|m4a|aac)(\?.*)?$/i; + +/** + * Check if URL is from a trusted domain + */ +function isTrustedDomain(url) { + try { + const parsed = new URL(url); + return TRUSTED_DOMAINS.has(parsed.hostname); + } catch { + return false; + } +} + +/** + * Check if a URL is already a proxy URL (from our server) + */ +function isProxyUrl(url) { + if (!url) return false; + return url.startsWith('/api/image-proxy'); +} + +/** + * Extract YouTube video ID + */ +function getYouTubeId(url) { + try { + const parsed = new URL(url); + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1); + } + if (parsed.hostname.includes('youtube.com')) { + return parsed.searchParams.get('v') || parsed.pathname.split('/').pop(); + } + } catch { + return null; + } + return null; +} + +/** + * Format file size + */ +function formatFileSize(bytes) { + if (!bytes) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(1)} ${units[unitIndex]}`; +} + +/** + * Format Discord timestamp + */ +function formatTimestamp(timestamp, format = 'full') { + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const isYesterday = new Date(now - 86400000).toDateString() === date.toDateString(); + + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + if (format === 'time') return time; + + if (isToday) return `Today at ${time}`; + if (isYesterday) return `Yesterday at ${time}`; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }) + ` at ${time}`; +} + +/** + * Format date for divider + */ +function formatDateDivider(timestamp) { + const date = new Date(timestamp); + return date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }); +} + +/** + * Parse archived messages from #dds-archive channel + * These messages were re-sent by a bot with this format: + * + * **Topic/Category** + * **YYYY-MM-DD HH:MM:SS.ssssss+00:00 (UTC) + * username**: message content + * + * Note: The ** wraps from the timestamp line through the username + * Some messages are nested (archive of an archive) and need recursive parsing + * + * Returns { originalAuthor, originalTimestamp, originalContent, topic } or null if not parseable + */ +function parseArchivedMessage(content, depth = 0) { + if (!content || depth > 3) return null; // Prevent infinite recursion + + // The format is: + // **Topic** + // **Timestamp (UTC) + // username**: content + + // Username cannot contain * or : characters, and ends with **: + // This prevents matching nested archive content + const boldPattern = /^\*\*(.+?)\*\*\s*\n\*\*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s*\(UTC\)\s*\n([^*:]+)\*\*:\s*([\s\S]*)$/; + const boldMatch = content.match(boldPattern); + + if (boldMatch) { + const topic = boldMatch[1].trim(); + const timestampStr = boldMatch[2].trim(); + let originalAuthor = boldMatch[3].trim(); + let originalContent = boldMatch[4].trim(); + + // Parse the timestamp + let originalTimestamp; + try { + const tsString = timestampStr.replace(' ', 'T'); + originalTimestamp = new Date(tsString); + if (isNaN(originalTimestamp.getTime())) { + originalTimestamp = new Date(tsString + 'Z'); + } + } catch { + return null; + } + + if (isNaN(originalTimestamp.getTime())) { + return null; + } + + // Check if the content is itself another archive format (nested archive) + // Pattern: **Topic**\n**username**: content (simpler format without timestamp) + const nestedPattern = /^\*\*(.+?)\*\*\s*\n\*\*([^*:]+)\*\*:\s*([\s\S]*)$/; + const nestedMatch = originalContent.match(nestedPattern); + if (nestedMatch) { + // This is a nested archive - use the inner author and content + originalAuthor = nestedMatch[2].trim(); + originalContent = nestedMatch[3].trim(); + } + + // Also try recursive parsing for deeply nested archives + const recursiveParsed = parseArchivedMessage(originalContent, depth + 1); + if (recursiveParsed) { + return { + originalAuthor: recursiveParsed.originalAuthor, + originalTimestamp: recursiveParsed.originalTimestamp || originalTimestamp.toISOString(), + originalContent: recursiveParsed.originalContent, + topic: recursiveParsed.topic || topic + }; + } + + return { + originalAuthor, + originalTimestamp: originalTimestamp.toISOString(), + originalContent, + topic + }; + } + + // Try simpler format: **Topic**\n**username**: content (no timestamp) + const simplePattern = /^\*\*(.+?)\*\*\s*\n\*\*([^*:]+)\*\*:\s*([\s\S]*)$/; + const simpleMatch = content.match(simplePattern); + if (simpleMatch) { + const topic = simpleMatch[1].trim(); + const originalAuthor = simpleMatch[2].trim(); + const originalContent = simpleMatch[3].trim(); + + return { + originalAuthor, + originalTimestamp: null, // No timestamp in this format + originalContent, + topic + }; + } + + // Fallback: try without bold markers (plain text format) + const lines = content.split('\n'); + if (lines.length < 3) return null; + + const topic = lines[0].replace(/^\*\*|\*\*$/g, '').trim(); + const timestampLine = lines[1].replace(/^\*\*/, '').trim(); + + // Parse timestamp - format: "2024-11-08 18:41:34.031000+00:00 (UTC)" + const timestampMatch = timestampLine.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s*(?:\(UTC\))?$/); + if (!timestampMatch) return null; + + // Get the rest as author**: message or author: message + const messagePart = lines.slice(2).join('\n').trim(); + + // Parse "author**: message" or "author: message" - author cannot contain * or : + const authorMatch = messagePart.match(/^([^*:]+)\*?\*?:\s*([\s\S]*)$/); + if (!authorMatch) return null; + + const originalAuthor = authorMatch[1].trim(); + const originalContent = authorMatch[2].trim(); + + // Parse the timestamp + let originalTimestamp; + try { + const tsString = timestampMatch[1].replace(' ', 'T'); + originalTimestamp = new Date(tsString); + if (isNaN(originalTimestamp.getTime())) { + originalTimestamp = new Date(timestampMatch[1].replace(' ', 'T') + 'Z'); + } + } catch { + return null; + } + + if (isNaN(originalTimestamp.getTime())) { + return null; + } + + return { + originalAuthor, + originalTimestamp: originalTimestamp.toISOString(), + originalContent, + topic + }; +} + +/** + * Hardcoded user data for archived messages + * Since these users may not be mentioned in current messages, we store their data directly + * This includes avatar URLs, display names, and colors from the database + */ +const ARCHIVE_USERS = { + // kriegerin -> cyberkriegerin (user_id: 992437729927376996) + 'kriegerin': { + id: '992437729927376996', + username: 'cyberkriegerin', + displayName: 'kriegerin', + avatar: 'https://cdn.discordapp.com/avatars/992437729927376996/3c4030cf3a210db4a180eab76e559ea2.png?size=1024', + color: null, + }, + // codey/Chris -> gizmo_a (user_id: 1172340700663255091) + 'codey': { + id: '1172340700663255091', + username: 'gizmo_a', + displayName: 'Chris', + avatar: 'https://cdn.discordapp.com/avatars/1172340700663255091/05b2a61faeba2363943a175df4ecb701.png?size=1024', + color: null, + }, + 'Chris': { + id: '1172340700663255091', + username: 'gizmo_a', + displayName: 'Chris', + avatar: 'https://cdn.discordapp.com/avatars/1172340700663255091/05b2a61faeba2363943a175df4ecb701.png?size=1024', + color: null, + }, + // Havoc bot (user_id: 1175471063438737519) + 'Havoc': { + id: '1175471063438737519', + username: 'Havoc', + displayName: 'Havoc', + avatar: 'https://cdn.discordapp.com/avatars/1175471063438737519/5e70b92d710a8584d27ca76220f93d67.png?size=1024', + color: null, + bot: true, + }, + // Deleted User / slip (user_id: 456226577798135808) + 'Deleted User': { + id: '456226577798135808', + username: 'slip', + displayName: 'poopboy', + avatar: 'https://codey.lol/images/456226577798135808.png', + color: null, + }, +}; + +/** + * Look up a real user from an archived username + * First checks hardcoded ARCHIVE_USERS, then falls back to usersMap + * If members data is provided, looks up color from there + * Returns user data if found, otherwise returns a basic object with just the username + */ +function resolveArchivedUser(archivedUsername, usersMap, members) { + // First check hardcoded archive users + if (ARCHIVE_USERS[archivedUsername]) { + const archivedUser = ARCHIVE_USERS[archivedUsername]; + // Look up color from members data if available + let color = archivedUser.color; + if (!color && members?.groups && archivedUser.id) { + for (const group of members.groups) { + const member = group.members?.find(m => m.id === archivedUser.id); + if (member?.color) { + color = member.color; + break; + } + } + } + return { + ...archivedUser, + color, + isArchiveResolved: true, + }; + } + + // Fall back to usersMap lookup by username match + if (usersMap) { + for (const [userId, userData] of Object.entries(usersMap)) { + if (userData.username === archivedUsername || userData.displayName === archivedUsername) { + // Look up color from members data if not in usersMap + let color = userData.color; + if (!color && members?.groups) { + for (const group of members.groups) { + const member = group.members?.find(m => m.id === userId); + if (member?.color) { + color = member.color; + break; + } + } + } + return { + ...userData, + id: userId, + color, + isArchiveResolved: true, + }; + } + } + } + + // Return basic user object if not found + return { + username: archivedUsername, + displayName: archivedUsername, + id: `archive-${archivedUsername}`, + isArchiveResolved: false, + }; +} + +/** + * Parse Discord markdown-like formatting + * @param {string} text - The text to parse + * @param {Object} options - Options for parsing + * @param {Map} options.channelMap - Map of channel IDs to channel objects + * @param {Object} options.usersMap - Map of user IDs to user objects { displayName, username, color } + * @param {Function} options.onChannelClick - Callback when channel is clicked + */ +function parseDiscordMarkdown(text, options = {}) { + if (!text) return ''; + + const { channelMap = new Map(), usersMap = {}, onChannelClick } = options; + + // Escape HTML first + let parsed = text + .replace(/&/g, '&') + .replace(//g, '>'); + + // Code blocks (``` ```) - add data-lenis-prevent for independent scrolling + // Must be processed first to prevent other formatting inside code + parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => { + return `
${code.trim()}
`; + }); + + // Inline code (`) - must be early to prevent formatting inside code + parsed = parsed.replace(/`([^`]+)`/g, '$1'); + + // Blockquotes (> at start of line) - process before newline conversion + // Group consecutive > lines into a single blockquote + parsed = parsed.replace(/(^|\n)((?:> .+(?:\n|$))+)/gm, (_, before, block) => { + const content = block + .split('\n') + .map(line => line.replace(/^> /, '')) + .join('\n'); + return `${before}
${content}
`; + }); + + // Headings (# ## ###) - must be at start of line + // Process before other inline formatting + parsed = parsed.replace(/(^|\n)### (.+?)(?=\n|$)/gm, (_, before, content) => { + return `${before}${content}`; + }); + parsed = parsed.replace(/(^|\n)## (.+?)(?=\n|$)/gm, (_, before, content) => { + return `${before}${content}`; + }); + parsed = parsed.replace(/(^|\n)# (.+?)(?=\n|$)/gm, (_, before, content) => { + return `${before}${content}`; + }); + + // Subtext/small text (-# at start of line) - process before newline conversion + parsed = parsed.replace(/(^|\n)-# (.+?)(?=\n|$)/gm, (_, before, content) => { + return `${before}${content}`; + }); + + // Unordered lists (- or * at start of line, but not ---) + parsed = parsed.replace(/(^|\n)[-*] (.+?)(?=\n|$)/gm, (_, before, content) => { + return `${before}• ${content}`; + }); + + // Ordered lists (1. 2. etc at start of line) + parsed = parsed.replace(/(^|\n)(\d+)\. (.+?)(?=\n|$)/gm, (_, before, num, content) => { + return `${before}${num}. ${content}`; + }); + + // Bold + Italic + Underline combinations (most specific first) + // ___***text***___ or ***___text___*** + parsed = parsed.replace(/(\*\*\*|___)(\*\*\*|___)([^*_]+)\2\1/g, '$3'); + + // Bold + Italic (***text***) + parsed = parsed.replace(/\*\*\*([^*]+)\*\*\*/g, '$1'); + + // Bold + Underline (__**text**__ or **__text__**) + parsed = parsed.replace(/__\*\*([^*_]+)\*\*__/g, '$1'); + parsed = parsed.replace(/\*\*__([^*_]+)__\*\*/g, '$1'); + + // Italic + Underline (__*text*__ or *__text__* or ___text___) + parsed = parsed.replace(/__\*([^*_]+)\*__/g, '$1'); + parsed = parsed.replace(/\*__([^*_]+)__\*/g, '$1'); + parsed = parsed.replace(/___([^_]+)___/g, '$1'); + + // Bold (**) + parsed = parsed.replace(/\*\*([^*]+)\*\*/g, '$1'); + + // Underline (__) - must come before italic _ handling + parsed = parsed.replace(/__([^_]+)__/g, '$1'); + + // Italic (* or _) + parsed = parsed.replace(/\*([^*]+)\*/g, '$1'); + parsed = parsed.replace(/\b_([^_]+)_\b/g, '$1'); + + // Strikethrough (~~) + parsed = parsed.replace(/~~([^~]+)~~/g, '$1'); + + // Spoiler (||) + parsed = parsed.replace(/\|\|([^|]+)\|\|/g, '$1'); + + // Discord Timestamps () + parsed = parsed.replace(/<t:(\d+)(?::([tTdDfFR]))?>/g, (_, timestamp, format) => { + const date = new Date(parseInt(timestamp) * 1000); + let formatted; + switch (format) { + case 't': // Short time (9:30 PM) + formatted = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + break; + case 'T': // Long time (9:30:00 PM) + formatted = date.toLocaleTimeString(); + break; + case 'd': // Short date (11/28/2024) + formatted = date.toLocaleDateString(); + break; + case 'D': // Long date (November 28, 2024) + formatted = date.toLocaleDateString([], { dateStyle: 'long' }); + break; + case 'f': // Short date/time (November 28, 2024 9:30 PM) + default: + formatted = date.toLocaleString([], { dateStyle: 'long', timeStyle: 'short' }); + break; + case 'F': // Long date/time (Thursday, November 28, 2024 9:30 PM) + formatted = date.toLocaleString([], { dateStyle: 'full', timeStyle: 'short' }); + break; + case 'R': // Relative (2 hours ago) + const now = Date.now(); + const diff = now - date.getTime(); + const seconds = Math.floor(Math.abs(diff) / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (diff < 0) { + // Future + if (years > 0) formatted = `in ${years} year${years > 1 ? 's' : ''}`; + else if (months > 0) formatted = `in ${months} month${months > 1 ? 's' : ''}`; + else if (days > 0) formatted = `in ${days} day${days > 1 ? 's' : ''}`; + else if (hours > 0) formatted = `in ${hours} hour${hours > 1 ? 's' : ''}`; + else if (minutes > 0) formatted = `in ${minutes} minute${minutes > 1 ? 's' : ''}`; + else formatted = `in ${seconds} second${seconds > 1 ? 's' : ''}`; + } else { + // Past + if (years > 0) formatted = `${years} year${years > 1 ? 's' : ''} ago`; + else if (months > 0) formatted = `${months} month${months > 1 ? 's' : ''} ago`; + else if (days > 0) formatted = `${days} day${days > 1 ? 's' : ''} ago`; + else if (hours > 0) formatted = `${hours} hour${hours > 1 ? 's' : ''} ago`; + else if (minutes > 0) formatted = `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + else formatted = `${seconds} second${seconds > 1 ? 's' : ''} ago`; + } + break; + } + return `${formatted}`; + }); + + // User mentions (<@123456789>) + parsed = parsed.replace(/<@!?(\d+)>/g, (_, userId) => { + const user = usersMap[userId]; + const displayName = user?.displayName || user?.username || 'User'; + const colorStyle = user?.color ? ` style="color: ${user.color}"` : ''; + return `@${displayName}`; + }); + + // @everyone and @here mentions + parsed = parsed.replace(/@(everyone|here)/g, '@$1'); + + // Channel mentions (<#123456789>) + parsed = parsed.replace(/<#(\d+)>/g, (_, channelId) => { + const channel = channelMap.get(channelId); + const channelName = channel?.name || 'channel'; + if (channel) { + return `#${channelName}`; + } + return `#${channelName}`; + }); + + // Role mentions (<@&123456789>) + parsed = parsed.replace(/<@&(\d+)>/g, '@role'); + + // Slash command mentions () + parsed = parsed.replace(/<\/([^:]+):(\d+)>/g, '/$1'); + + // Custom emoji (<:name:123456789> or ) + parsed = parsed.replace(/<(a)?:(\w+):(\d+)>/g, (_, animated, name, id) => { + const ext = animated ? 'gif' : 'png'; + return `:${name}:`; + }); + + // Unicode emoji (keep as-is, they render natively) + + // Masked/Markdown links [text](url) or [text](url "title") - process before bare URL detection + parsed = parsed.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"]+)")?\)/g, + (_, text, url, title) => { + const titleAttr = title ? ` title="${title}"` : ''; + return `${text}`; + } + ); + + // Links (URLs) - use negative lookbehind to skip URLs in HTML attributes (src=", href=") + // Match URLs that are NOT preceded by =" or =' + parsed = parsed.replace( + /(?"']+)/g, + '$1' + ); + + // Newlines + parsed = parsed.replace(/\n/g, '
'); + + // Unescape Discord markdown escape sequences (\_ \* \~ \` \| \\) + // Must be done after all markdown processing + parsed = parsed.replace(/\\([_*~`|\\])/g, '$1'); + + return parsed; +} + +/** + * Extract URLs from text + */ +function extractUrls(text) { + if (!text) return []; + const urlRegex = /(https?:\/\/[^\s<]+)/g; + const matches = text.match(urlRegex); + return matches || []; +} + +// ============================================================================ +// Link Preview Component +// ============================================================================ + +const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoad }) { + const [preview, setPreview] = useState(cachedPreview || null); + const [loading, setLoading] = useState(!cachedPreview); + const [error, setError] = useState(false); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + if (cachedPreview) { + setPreview(cachedPreview); + setLoading(false); + return; + } + + // For direct media URLs, only allow trusted domains + if (IMAGE_EXTENSIONS.test(url)) { + if (isTrustedDomain(url)) { + const previewData = { url, type: 'image', image: url, trusted: true }; + setPreview(previewData); + setLoading(false); + onPreviewLoad?.(url, previewData); + } else { + // Fetch through server to get metadata without exposing user IP + fetchPreviewFromServer(); + } + return; + } + if (VIDEO_EXTENSIONS.test(url)) { + if (isTrustedDomain(url)) { + const previewData = { url, type: 'video', video: url, trusted: true }; + setPreview(previewData); + setLoading(false); + onPreviewLoad?.(url, previewData); + } else { + // Don't show untrusted video embeds - too risky + setError(true); + setLoading(false); + } + return; + } + + // Check for YouTube - trusted domain, can embed directly + const ytId = getYouTubeId(url); + if (ytId) { + const previewData = { + url, + type: 'youtube', + videoId: ytId, + title: 'YouTube Video', + siteName: 'YouTube', + themeColor: '#FF0000', + trusted: true, + }; + setPreview(previewData); + setLoading(false); + onPreviewLoad?.(url, previewData); + return; + } + + // All other URLs: fetch preview from server + fetchPreviewFromServer(); + + async function fetchPreviewFromServer() { + try { + const response = await authFetch(`/api/link-preview?url=${encodeURIComponent(url)}`); + if (!response.ok) throw new Error('Failed to fetch'); + const data = await response.json(); + if (data.error) throw new Error(data.error); + setPreview(data); + onPreviewLoad?.(url, data); + } catch (err) { + console.warn('Failed to fetch link preview:', url, err); + setError(true); + } finally { + setLoading(false); + } + } + }, [url, cachedPreview, onPreviewLoad]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !preview) { + return null; // Don't show anything for failed previews + } + + // YouTube embed - trusted, use iframe + if (preview.type === 'youtube' && preview.videoId) { + return ( +
+
+
YouTube
+ {preview.title && ( + + {preview.title} + + )} +
+
+