diff --git a/src/assets/styles/DiscordLogs.css b/src/assets/styles/DiscordLogs.css
index 6b6d753..cf8fb90 100644
--- a/src/assets/styles/DiscordLogs.css
+++ b/src/assets/styles/DiscordLogs.css
@@ -1,3 +1,199 @@
+/* Constrained poll result container */
+.discord-poll-result-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ background: #f6f6fa;
+ border: 1px solid #e3e5e8;
+ border-radius: 10px;
+ padding: 0.75rem 1rem;
+ box-shadow: 0 1px 4px rgba(88,101,242,0.04);
+ max-width: 520px; /* wider container so bar and meta fit comfortably */
+ flex: 1;
+ width: 100%;
+ box-sizing: border-box;
+ position: relative; /* for timestamp */
+}
+
+[data-theme="dark"] .discord-poll-result-message {
+ background: #232428;
+ border-color: #36393f;
+}
+
+
+[data-theme="dark"] .discord-poll-result-message {
+ background: #232428;
+ border-color: #36393f;
+}
+
+.discord-poll-result-content {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* Header */
+.discord-poll-result-header {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #5865f2;
+ margin-bottom: 0.25rem;
+}
+[data-theme="dark"] .discord-poll-result-header {
+ color: #8ea1e1;
+}
+
+/* Winner Row */
+.discord-poll-result-winner {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ justify-content: flex-start;
+ font-size: 1rem;
+ font-weight: 700;
+ color: #23272a;
+ /* allow the bar/meta to wrap when space is tight */
+ flex-wrap: wrap;
+}
+
+[data-theme="dark"] .discord-poll-result-winner {
+ color: #fff;
+}
+
+.discord-poll-result-emoji {
+ font-size: 1.25rem;
+}
+.discord-poll-result-check {
+ color: #43b581;
+}
+
+/* Poll Bar */
+.discord-poll-result-bar {
+ /* make the bar flexible so it can shrink/grow depending on container */
+ flex: 1 1 260px; /* preferred 260px but allow shrinking */
+ min-width: 0; /* allow shrink on very small screens */
+ height: 34px; /* slightly taller to match Discord feel */
+ border-radius: 6px;
+ background: rgba(0,0,0,0.12);
+ overflow: hidden;
+}
+
+[data-theme="dark"] .discord-poll-result-bar {
+ background: rgba(255,255,255,0.04);
+}
+
+.discord-poll-result-fill {
+ height: 100%;
+ background: linear-gradient(90deg, rgba(88,101,242,1) 0%, rgba(99,114,255,1) 100%);
+ display: flex;
+ align-items: center;
+ padding: 0 0.85rem;
+ font-size: 0.95rem;
+ color: #fff;
+ font-weight: 600;
+ transition: width 0.35s ease;
+}
+
+.discord-poll-result-fill-content {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ /* allow answer text to ellipsize when space is limited */
+ min-width: 0;
+}
+
+.discord-poll-result-answer {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: inline-block;
+ max-width: 100%;
+}
+
+/* Votes */
+.discord-poll-result-meta {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ font-size: 0.88rem;
+ color: #8b8f99;
+ margin-left: 0.5rem;
+ /* allow meta to shrink so it doesn't force the bar to overflow */
+ flex-shrink: 1;
+ min-width: 0;
+}
+
+/* On narrow containers allow meta to sit below the bar to avoid overflow */
+@media (max-width: 440px) {
+ .discord-poll-result-winner {
+ gap: 0.5rem;
+ }
+ .discord-poll-result-bar {
+ flex-basis: 100%;
+ width: 100%;
+ }
+ .discord-poll-result-meta {
+ width: 100%;
+ margin-left: 0;
+ justify-content: flex-start;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ }
+ .discord-poll-result-percent {
+ margin-right: 0.5rem;
+ }
+}
+[data-theme="dark"] .discord-poll-result-meta {
+ color: #b5bac1;
+}
+.discord-poll-result-votes {
+ opacity: 0.9;
+}
+
+.discord-poll-result-percent,
+.discord-poll-result-votes {
+ white-space: nowrap; /* keep percent and votes compact */
+ font-weight: 700;
+}
+
+/* When the timestamp appears in the poll result, ensure it doesn't overlap the content */
+.discord-poll-result-message > .discord-timestamp {
+ margin-left: 0.5rem;
+ flex-shrink: 0;
+}
+
+@media (max-width: 440px) {
+ /* Move timestamp to its own row on very small screens */
+ .discord-poll-result-message > .discord-timestamp {
+ width: 100%;
+ margin-left: 0;
+ margin-top: 0.25rem;
+ justify-self: flex-end;
+ }
+}
+
+/* View Poll Button */
+.discord-poll-result-view-btn {
+ align-self: flex-start;
+ margin: 0.25rem 0.5rem 0;
+ background: #5865f2;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.3rem 0.9rem;
+ font-size: 0.95rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.discord-poll-result-view-btn:hover {
+ background: #4752c4;
+}
+
+
/* Discord Logs Archive Styles */
.discord-logs-container {
@@ -10,6 +206,7 @@
display: flex;
gap: 1.5rem;
min-height: 500px;
+ align-items: stretch;
}
/* Content area (messages) */
@@ -66,15 +263,17 @@
display: flex;
align-items: center;
gap: 0.35rem;
- font-size: 0.9rem;
- color: #5c5f66;
+ font-size: 1rem; /* slightly larger */
+ color: var(--text-color, #2e3338);
margin-top: 0.15rem;
flex: 1;
min-width: 0;
+ font-weight: 700; /* stronger, similar to Discord header */
+ letter-spacing: -0.01em;
}
[data-theme="dark"] .discord-channel-name {
- color: #b5bac1;
+ color: #f2f3f5;
}
.discord-channel-name svg {
@@ -474,6 +673,43 @@
color: #949ba4;
}
+/* Jump to message button (shown in search results) */
+.discord-jump-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.15rem 0.5rem;
+ margin-left: 0.5rem;
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: #5865f2;
+ background: rgba(88, 101, 242, 0.1);
+ border: 1px solid rgba(88, 101, 242, 0.3);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.discord-jump-btn:hover {
+ background: rgba(88, 101, 242, 0.2);
+ border-color: rgba(88, 101, 242, 0.5);
+}
+
+.discord-jump-btn svg {
+ flex-shrink: 0;
+}
+
+[data-theme="dark"] .discord-jump-btn {
+ background: rgba(88, 101, 242, 0.15);
+ border-color: rgba(88, 101, 242, 0.4);
+ color: #8b9dff;
+}
+
+[data-theme="dark"] .discord-jump-btn:hover {
+ background: rgba(88, 101, 242, 0.25);
+ border-color: rgba(88, 101, 242, 0.6);
+}
+
.discord-text {
font-size: 0.9375rem;
line-height: 1.375;
@@ -659,6 +895,208 @@
color: #b9bbbe;
}
+/* Polls */
+.discord-poll {
+ background: rgba(0, 0, 0, 0.04);
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
+ padding: 0.75rem;
+ margin-top: 0.5rem;
+ max-width: 400px;
+}
+
+[data-theme="dark"] .discord-poll {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.discord-poll.finalized {
+ opacity: 0.85;
+}
+
+.discord-poll-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.discord-poll-emoji {
+ width: 22px;
+ height: 22px;
+ object-fit: contain;
+}
+
+.discord-poll-question {
+ font-weight: 600;
+ font-size: 0.95rem;
+ color: var(--text-color, #2e3338);
+ flex: 1;
+}
+
+[data-theme="dark"] .discord-poll-question {
+ color: #f2f3f5;
+}
+
+.discord-poll-multiselect {
+ font-size: 0.7rem;
+ color: var(--text-muted, #72767d);
+ background: rgba(88, 101, 242, 0.1);
+ padding: 0.15rem 0.4rem;
+ border-radius: 4px;
+}
+
+.discord-poll-answers {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.discord-poll-answer {
+ position: relative;
+ background: rgba(0, 0, 0, 0.03);
+ border-radius: 6px;
+ overflow: hidden;
+ min-height: 32px;
+}
+
+[data-theme="dark"] .discord-poll-answer {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.discord-poll-answer-bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: rgba(88, 101, 242, 0.25);
+ border-radius: 6px;
+ transition: width 0.3s ease;
+}
+
+[data-theme="dark"] .discord-poll-answer-bar {
+ background: rgba(88, 101, 242, 0.35);
+}
+
+.discord-poll-answer-content {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.6rem;
+ z-index: 1;
+}
+
+.discord-poll-answer-emoji {
+ width: 18px;
+ height: 18px;
+ object-fit: contain;
+ flex-shrink: 0;
+}
+
+.discord-poll-answer-text {
+ flex: 1;
+ font-size: 0.875rem;
+ color: var(--text-color, #2e3338);
+}
+
+[data-theme="dark"] .discord-poll-answer-text {
+ color: #dcddde;
+}
+
+.discord-poll-answer-votes {
+ font-size: 0.75rem;
+ color: var(--text-muted, #72767d);
+ white-space: nowrap;
+}
+
+.discord-poll-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
+ font-size: 0.75rem;
+ color: var(--text-muted, #72767d);
+}
+
+[data-theme="dark"] .discord-poll-footer {
+ border-top-color: rgba(255, 255, 255, 0.06);
+}
+
+.discord-poll-status {
+ color: #ed4245;
+}
+
+.discord-poll-expiry {
+ color: #5865f2;
+}
+
+/* Poll voters */
+.discord-poll-voters {
+ display: flex;
+ align-items: center;
+ gap: 0.15rem;
+ padding: 0.25rem 0.5rem;
+ flex-wrap: wrap;
+}
+
+.discord-poll-voter {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ overflow: hidden;
+ background: rgba(88, 101, 242, 0.2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.6rem;
+ color: #5865f2;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+.discord-poll-voter img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* Improved voter visuals */
+.discord-poll-voters { padding: 0.25rem 0.5rem 0.5rem 0.5rem; gap: 0.35rem }
+.discord-poll-voter { width: 26px; height: 26px; font-size:0.75rem; border-radius:50%; border:1px solid rgba(0,0,0,0.08); box-shadow:0 1px 0 rgba(255,255,255,0.03) inset; transition: transform 0.12s ease, box-shadow 0.12s ease; }
+.discord-poll-voter:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(3,8,23,0.2); z-index: 4 }
+.discord-poll-voter span { display:flex; align-items:center; justify-content:center }
+.discord-poll-voters-more { margin-left: 0.5rem; font-size:0.8rem }
+
+/* Modal styling for voters */
+.discord-modal-overlay { position: fixed; inset: 0; background: rgba(2,6,23,0.55); display:flex; align-items:center; justify-content:center; z-index:1005; padding:1.25rem }
+.discord-modal { width: min(680px, 95%); background: var(--card-bg, #ffffff); border-radius: 12px; box-shadow: 0 20px 60px rgba(2,6,23,0.6); overflow: hidden; color: var(--text-color, #222); }
+.discord-modal-header { display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1rem; border-bottom: 1px solid rgba(0,0,0,0.06) }
+.discord-modal-title { font-weight:600; font-size:1rem }
+.discord-modal-close { background:none; border: none; font-size:1.05rem; cursor:pointer; padding:0.35rem 0.5rem }
+.discord-modal-body { padding:0.5rem 0.75rem 1rem; max-height: 56vh; overflow:auto }
+.discord-modal-voters-list { display:flex; flex-direction:column; gap:0.5rem; padding:0.25rem }
+.discord-modal-voter { display:flex; gap:0.75rem; align-items:center; padding:0.5rem;border-radius:8px; transition: background 0.12s }
+.discord-modal-voter:hover { background: rgba(88,101,242,0.04) }
+.discord-modal-voter-avatar { width:44px; height:44px; border-radius:50%; overflow:hidden; display:flex;align-items:center;justify-content:center;background: rgba(88,101,242,0.08); color: #5865f2; border:1px solid rgba(0,0,0,0.06)}
+.discord-modal-voter-avatar img{width:100%;height:100%;object-fit:cover}
+.discord-modal-voter-info { display:flex; flex-direction:column }
+.discord-modal-voter-name { font-weight:600 }
+.discord-modal-voter-username { font-size:0.85rem;color:var(--text-muted,#777) }
+.discord-modal-empty { padding:1rem; color:var(--text-muted,#777); text-align:center }
+
+[data-theme="dark"] .discord-modal { background: #17181a; color:#fff }
+[data-theme="dark"] .discord-modal-header { border-bottom-color: rgba(255,255,255,0.04) }
+[data-theme="dark"] .discord-modal-voter:hover { background: rgba(255,255,255,0.02) }
+[data-theme="dark"] .discord-modal-voter-username { color: #b5bac1 }
+
+.discord-poll-voters-more {
+ font-size: 0.65rem;
+ color: var(--text-muted, #72767d);
+ margin-left: 0.25rem;
+}
+
/* Attachments */
.discord-attachments {
display: flex;
@@ -971,6 +1409,14 @@ a.discord-embed-title:hover {
background: #000;
}
+.discord-embed-video-iframe {
+ width: 400px;
+ max-width: 100%;
+ height: 225px;
+ border: none;
+ border-radius: 4px;
+}
+
/* Reactions */
.discord-reactions {
display: flex;
@@ -1271,16 +1717,29 @@ a.discord-embed-title:hover {
overflow: auto;
max-height: 400px;
overscroll-behavior: contain;
- font-family: var(--font-mono);
- font-size: 0.875rem;
- line-height: 1.4;
+ /* prefer modern developer-friendly monospace fonts, avoid ligatures for ASCII art */
+ font-family: ui-monospace, "JetBrains Mono", "Fira Code", "Roboto Mono", "Consolas", "Monaco", "Courier New", monospace;
+ font-variant-ligatures: none;
+ font-feature-settings: "liga" 0, "calt" 0;
+ font-size: 0.92rem; /* readable size for ASCII art */
+ line-height: 1.04; /* tighter line height to preserve vertical alignment */
+ white-space: pre;
+ tab-size: 4;
+ -moz-tab-size: 4;
+ letter-spacing: 0;
}
.discord-code-block code {
background: none;
padding: 0;
color: #b5bac1;
- white-space: pre;
+ white-space: pre; /* keep exact spacing */
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ letter-spacing: inherit;
+ tab-size: 4; /* consistent tab rendering */
+ -moz-tab-size: 4;
}
[data-theme="light"] .discord-code-block {
@@ -1423,14 +1882,22 @@ a.discord-embed-title:hover {
.discord-sidebar {
display: flex;
- flex-direction: column;
- gap: 1rem;
- width: 220px;
+ flex-direction: row;
+ gap: 0;
+ width: 260px;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.02);
border-radius: 8px;
- padding: 0.75rem;
- max-height: 600px;
+ overflow: hidden;
+ align-self: stretch;
+ max-height: calc(100vh - 200px);
+}
+
+.discord-sidebar-channels {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
@@ -1439,45 +1906,48 @@ a.discord-embed-title:hover {
background: rgba(255, 255, 255, 0.02);
}
-/* Guild List (server icons) */
+/* Guild List (server icons) - compact vertical strip */
.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);
+ flex-direction: column;
+ gap: 0.35rem;
+ padding: 0.35rem;
+ border-right: 1px solid rgba(0, 0, 0, 0.08);
+ background: rgba(0, 0, 0, 0.02);
+ flex-shrink: 0;
}
[data-theme="dark"] .discord-guild-list {
border-right-color: rgba(255, 255, 255, 0.08);
+ background: rgba(0, 0, 0, 0.15);
}
.discord-guild-btn {
- width: 48px;
- height: 48px;
- border-radius: 24px;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
background: rgba(0, 0, 0, 0.06);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
- transition: all 0.2s ease;
+ transition: all 0.15s ease;
overflow: hidden;
+ flex-shrink: 0;
}
[data-theme="dark"] .discord-guild-btn {
- background: rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.08);
}
.discord-guild-btn:hover {
- border-radius: 16px;
- background: rgba(88, 101, 242, 0.2);
+ border-radius: 35%;
+ background: rgba(88, 101, 242, 0.25);
}
.discord-guild-btn.active {
- border-radius: 16px;
+ border-radius: 35%;
background: #5865f2;
}
@@ -1488,7 +1958,7 @@ a.discord-embed-title:hover {
}
.discord-guild-initial {
- font-size: 1.25rem;
+ font-size: 0.8rem;
font-weight: 600;
color: #4f545c;
}
@@ -1534,11 +2004,14 @@ a.discord-embed-title:hover {
gap: 0.25rem;
padding: 0.5rem 0.4rem 0.25rem;
margin-top: 0.5rem;
- font-size: 0.7rem;
+ font-size: 0.65rem;
font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.02em;
+ letter-spacing: 0.01em;
color: #6d6f78;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
}
[data-theme="dark"] .discord-category-header {
@@ -1593,6 +2066,18 @@ a.discord-embed-title:hover {
color: #8b9dff;
}
+/* Thread button styling - indented under parent channel */
+.discord-sidebar .discord-thread-btn {
+ padding-left: 2rem;
+ font-size: 0.8rem;
+ opacity: 0.85;
+}
+
+.discord-sidebar .discord-thread-btn svg {
+ width: 14px;
+ height: 14px;
+}
+
/* ============================================================================
Load More Button
============================================================================ */
@@ -1649,22 +2134,26 @@ a.discord-embed-title:hover {
.discord-sidebar {
width: 100%;
max-height: 250px;
- flex-direction: row;
- flex-wrap: wrap;
- align-items: flex-start;
+ flex-direction: column;
}
.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;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
+ border-right: none;
+ padding: 0.35rem;
+ background: transparent;
}
[data-theme="dark"] .discord-guild-list {
- border-right-color: rgba(255, 255, 255, 0.08);
+ border-bottom-color: rgba(255, 255, 255, 0.08);
+ background: transparent;
+ }
+
+ .discord-sidebar-channels {
+ flex: 1;
+ overflow-y: auto;
}
.discord-sidebar .discord-channel-list {
diff --git a/src/assets/styles/MemeGrid.css b/src/assets/styles/MemeGrid.css
index 7fbd931..36189b7 100644
--- a/src/assets/styles/MemeGrid.css
+++ b/src/assets/styles/MemeGrid.css
@@ -101,3 +101,51 @@
.meme-dialog-nav-next {
right: 0.5rem;
}
+
+/* Skeleton loader for images */
+.meme-skeleton {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ rgba(128, 128, 128, 0.1) 0%,
+ rgba(128, 128, 128, 0.2) 50%,
+ rgba(128, 128, 128, 0.1) 100%
+ );
+ background-size: 200% 100%;
+ animation: skeleton-shimmer 1.5s ease-in-out infinite;
+ border-radius: 6px;
+}
+
+@keyframes skeleton-shimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+.meme-img-loading {
+ opacity: 0;
+}
+
+.meme-img {
+ transition: opacity 0.3s ease;
+}
+
+/* Mobile swipe hint */
+@media (max-width: 768px) {
+ .meme-dialog-nav {
+ opacity: 0.5;
+ width: 2rem;
+ height: 2rem;
+ }
+
+ .meme-dialog-body {
+ touch-action: pan-y pinch-zoom;
+ }
+}
diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css
index 0ae17a4..8b4af76 100644
--- a/src/assets/styles/global.css
+++ b/src/assets/styles/global.css
@@ -4,22 +4,23 @@
@theme {
/* Font families */
- --font-sans: "Geist Sans", system-ui, sans-serif;
+ --font-sans: "IBM Plex Sans", "Geist Sans", system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, monospace;
}
::selection {
- background-color: #47a3f3;
- color: #fefefe;
+ background-color: #3b82f6;
+ color: #ffffff;
}
/* Dark theme colors */
[data-theme="dark"] {
- background-color: #121212;
+ background-color: #0a0a0a;
}
html {
min-width: 360px;
+ scroll-behavior: smooth;
}
.prose {
@@ -200,9 +201,24 @@ Custom
width: 64px;
}
+/* Page section - consistent spacing for all page content */
+.page-section {
+ width: 100%;
+}
+
.footer {
display: grid;
align-items: end;
+ padding: 2.5rem 0 2rem 0;
+ margin-top: auto;
+ padding-top: 3rem;
+ text-align: center;
+ font-size: 0.95rem;
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+[data-theme="dark"] .footer {
+ border-top-color: rgba(255, 255, 255, 0.06);
}
.header-text, .footer-text {
@@ -235,8 +251,103 @@ Custom
margin-left: 50%;
}
-#exclude-checkboxes {
- margin-left: 5.5%;
+/* Search button */
+.search-btn {
+ padding: 0.625rem 1.5rem;
+ background: linear-gradient(135deg, #171717 0%, #262626 100%);
+ color: white;
+ border: none;
+ border-radius: 10px;
+ font-weight: 600;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.search-btn:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.search-btn:active {
+ transform: translateY(0);
+}
+
+[data-theme="dark"] .search-btn {
+ background: linear-gradient(135deg, #fafafa 0%, #e5e5e5 100%);
+ color: #171717;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+[data-theme="dark"] .search-btn:hover {
+ box-shadow: 0 4px 16px rgba(255, 255, 255, 0.15);
+}
+
+/* Exclude sources - toggle chips */
+.exclude-sources {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.exclude-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #737373;
+ margin-right: 0.125rem;
+}
+
+[data-theme="dark"] .exclude-label {
+ color: #525252;
+}
+
+.exclude-chip {
+ display: inline-flex;
+ align-items: center;
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.375rem 0.75rem;
+ border-radius: 9999px;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ background: rgba(0, 0, 0, 0.03);
+ color: #525252;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ user-select: none;
+}
+
+[data-theme="dark"] .exclude-chip {
+ border-color: rgba(255, 255, 255, 0.25);
+ background: rgba(255, 255, 255, 0.06);
+ color: #d4d4d4;
+}
+
+.exclude-chip:hover {
+ border-color: rgba(239, 68, 68, 0.4);
+ color: #dc2626;
+ background: rgba(239, 68, 68, 0.05);
+}
+
+[data-theme="dark"] .exclude-chip:hover {
+ border-color: rgba(248, 113, 113, 0.4);
+ color: #f87171;
+ background: rgba(248, 113, 113, 0.08);
+}
+
+/* Active/excluded state */
+.exclude-chip--active {
+ border-color: rgba(239, 68, 68, 0.5);
+ background: rgba(239, 68, 68, 0.1);
+ color: #dc2626;
+ text-decoration: line-through;
+ text-decoration-thickness: 1.5px;
+}
+
+[data-theme="dark"] .exclude-chip--active {
+ border-color: rgba(248, 113, 113, 0.5);
+ background: rgba(248, 113, 113, 0.12);
+ color: #f87171;
}
#lyric-search-input {
@@ -247,7 +358,7 @@ Custom
.lyric-search-input-wrapper {
position: relative;
width: 100%;
- max-width: 900px;
+ max-width: 640px;
}
.lyric-search-input-wrapper .p-autocomplete {
@@ -255,19 +366,46 @@ Custom
}
.lyric-search-input-wrapper .p-autocomplete-input {
- padding-right: 2.5rem;
+ width: 100%;
+ padding: 0.875rem 2.75rem 0.875rem 1.125rem;
+ border-radius: 12px;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ font-size: 1rem;
+ background: white;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.lyric-search-input-wrapper .p-autocomplete-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
+}
+
+[data-theme="dark"] .lyric-search-input-wrapper .p-autocomplete-input {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.15);
+ color: #f5f5f5;
+}
+
+[data-theme="dark"] .lyric-search-input-wrapper .p-autocomplete-input:focus {
+ border-color: #60a5fa;
+ box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.15);
+}
+
+[data-theme="dark"] .lyric-search-input-wrapper .p-autocomplete-input::placeholder {
+ color: #a3a3a3;
}
.input-status-icon {
position: absolute;
- right: 0.85rem;
- top: 0;
- bottom: 0;
- transform: none;
+ right: 1rem;
+ top: 50%;
+ transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
+ z-index: 10;
transition: opacity 0.2s ease, color 0.2s ease;
}
@@ -282,10 +420,29 @@ Custom
}
.lyrics-card {
- border-radius: 12px;
- box-shadow: 0 4px 12px rgba(0,0,0,0.05);
+ border-radius: 16px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 8px 24px rgba(0,0,0,0.04);
padding: 1.5rem;
- transition: background 0.3s;
+ transition: background 0.3s, box-shadow 0.3s;
+ background: white;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+[data-theme="dark"] .lyrics-card {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.08);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2), 0 8px 24px rgba(0,0,0,0.15);
+}
+
+.lyrics-card-animate {
+ opacity: 0;
+ transform: translateY(12px);
+ transition: opacity 0.4s ease-out, transform 0.4s ease-out;
+}
+
+.lyrics-card-visible {
+ opacity: 1;
+ transform: translateY(0);
}
.lyrics-toolbar {
@@ -294,11 +451,18 @@ Custom
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
- margin-bottom: 1rem;
+ margin-bottom: 1.25rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+[data-theme="dark"] .lyrics-toolbar {
+ border-bottom-color: rgba(255, 255, 255, 0.08);
}
.lyrics-title {
font-weight: 600;
+ font-size: 1.1rem;
flex: 1;
text-align: left;
}
@@ -306,22 +470,27 @@ Custom
.lyrics-actions {
display: flex;
align-items: center;
- gap: 0.35rem;
+ gap: 0.5rem;
}
.text-size-buttons {
display: flex;
- border: 1px solid rgba(79, 70, 229, 0.25);
- border-radius: 999px;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
overflow: hidden;
- background: rgba(79, 70, 229, 0.06);
+ background: rgba(0, 0, 0, 0.03);
+}
+
+[data-theme="dark"] .text-size-buttons {
+ border-color: rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.05);
}
.text-size-btn {
background: transparent;
border: none;
color: inherit;
- padding: 0.15rem 0.5rem;
+ padding: 0.25rem 0.6rem;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
@@ -332,13 +501,17 @@ Custom
}
.text-size-btn.active {
- background: rgba(79, 70, 229, 0.15);
+ background: rgba(0, 0, 0, 0.08);
font-weight: 600;
}
+[data-theme="dark"] .text-size-btn.active {
+ background: rgba(255, 255, 255, 0.12);
+}
+
.lyrics-content {
line-height: 2.0;
- font-family: 'Inter', sans-serif;
+ font-family: 'IBM Plex Sans', 'Inter', sans-serif;
font-size: 1rem;
white-space: pre-wrap;
}
@@ -348,6 +521,32 @@ Custom
line-height: 1.85;
}
+.lyrics-verse {
+ padding: 0.5rem 0.75rem;
+ margin: 0.25rem -0.75rem;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.lyrics-verse:hover {
+ background-color: rgba(79, 70, 229, 0.06);
+}
+
+.lyrics-verse-highlighted {
+ background-color: rgba(79, 70, 229, 0.15);
+ box-shadow: inset 3px 0 0 rgba(79, 70, 229, 0.6);
+}
+
+.lyrics-card-dark .lyrics-verse:hover {
+ background-color: rgba(139, 92, 246, 0.1);
+}
+
+.lyrics-card-dark .lyrics-verse-highlighted {
+ background-color: rgba(139, 92, 246, 0.2);
+ box-shadow: inset 3px 0 0 rgba(139, 92, 246, 0.7);
+}
+
.lyrics-action-button {
color: inherit;
border: 1px solid transparent;
@@ -384,10 +583,52 @@ Custom
padding-bottom: 3%;
}
+/* PrimeReact AutoComplete Panel - Global Styling */
+.p-autocomplete-panel {
+ background: white;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
+ overflow: hidden;
+ z-index: 9999;
+}
+
+[data-theme="dark"] .p-autocomplete-panel {
+ background: #1a1a1a;
+ border-color: rgba(255, 255, 255, 0.1);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+}
+
.p-autocomplete-items {
max-height: 200px !important;
overflow-y: auto !important;
overscroll-behavior: contain;
+ padding: 0.25rem;
+}
+
+.p-autocomplete-item {
+ padding: 0.625rem 0.875rem;
+ border-radius: 8px;
+ margin: 0.125rem 0;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ color: #262626;
+}
+
+.p-autocomplete-item:hover,
+.p-autocomplete-item.p-highlight {
+ background: rgba(59, 130, 246, 0.1);
+ color: #1d4ed8;
+}
+
+[data-theme="dark"] .p-autocomplete-item {
+ color: #e5e5e5;
+}
+
+[data-theme="dark"] .p-autocomplete-item:hover,
+[data-theme="dark"] .p-autocomplete-item.p-highlight {
+ background: rgba(96, 165, 250, 0.15);
+ color: #60a5fa;
}
.p-autocomplete-input {
@@ -396,6 +637,7 @@ Custom
border: 1px solid #ccc;
transition: border 0.2s;
}
+
.p-autocomplete-input:focus {
border-color: #4f46e5;
outline: none;
@@ -510,18 +752,52 @@ Custom
/*
Toastify customizations
*/
-.Toastify__toast--error {
- background-color: rgba(255, 0, 0, 0.5) !important;
- color: inherit !important;
+.Toastify__toast {
+ border-radius: 12px !important;
+ backdrop-filter: blur(12px) !important;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3) !important;
+ font-family: 'IBM Plex Sans', sans-serif !important;
+ font-size: 0.9rem !important;
+ background: rgba(30, 30, 30, 0.95) !important;
+ color: #e5e5e5 !important;
}
+
+.Toastify__toast--error {
+ background: rgba(30, 30, 30, 0.95) !important;
+ border-left: 4px solid #ef4444 !important;
+ color: #fca5a5 !important;
+}
+
.Toastify__toast--info {
- background-color: rgba(217, 242, 255, 0.8) !important;
- color: #000 !important;
+ background: rgba(30, 30, 30, 0.95) !important;
+ border-left: 4px solid #3b82f6 !important;
+ color: #93c5fd !important;
}
.Toastify__toast--success {
- background-color: rgba(46, 186, 106, 0.8) !important;
- color: inherit !important;
+ background: rgba(30, 30, 30, 0.95) !important;
+ border-left: 4px solid #22c55e !important;
+ color: #86efac !important;
+}
+
+.Toastify__toast--warning {
+ background: rgba(30, 30, 30, 0.95) !important;
+ border-left: 4px solid #f59e0b !important;
+ color: #fcd34d !important;
+}
+
+.Toastify__close-button {
+ color: #a3a3a3 !important;
+ opacity: 0.7 !important;
+}
+
+.Toastify__close-button:hover {
+ opacity: 1 !important;
+ color: #e5e5e5 !important;
+}
+
+.Toastify__progress-bar {
+ background: rgba(255, 255, 255, 0.2) !important;
}
.Toastify__toast--success > .Toastify__toast-icon svg {
@@ -531,3 +807,8 @@ Toastify customizations
.Toastify__toast--success > .Toastify__toast-icon::after {
content: "🦄" !important;
}
+
+/* Light mode - keep dark toasts */
+[data-theme="light"] .Toastify__toast {
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2) !important;
+}
diff --git a/src/assets/styles/nav.css b/src/assets/styles/nav.css
index cd8386d..d9b7812 100644
--- a/src/assets/styles/nav.css
+++ b/src/assets/styles/nav.css
@@ -79,22 +79,34 @@ nav {
.nav-user-inline {
display: inline-flex;
align-items: center;
- gap: 0.35rem;
- padding: 0.3rem 0.85rem;
+ gap: 0.4rem;
+ padding: 0.35rem 0.9rem;
border-radius: 999px;
- border: 1px solid rgba(148, 163, 184, 0.4);
- font-size: 0.82rem;
+ border: 1px solid rgba(148, 163, 184, 0.3);
+ font-size: 0.8rem;
font-weight: 500;
- color: #1e293b;
- background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(226, 232, 240, 0.85));
- box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.45);
+ font-family: 'IBM Plex Sans', sans-serif;
+ color: #374151;
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(241, 245, 249, 0.9));
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.6);
+ transition: all 0.2s ease;
+}
+
+.nav-user-inline:hover {
+ border-color: rgba(148, 163, 184, 0.5);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
[data-theme="dark"] .nav-user-inline {
- color: #f1f5f9;
- border-color: rgba(59, 130, 246, 0.25);
- background: linear-gradient(120deg, rgba(15, 23, 42, 0.85), rgba(30, 41, 59, 0.7));
- box-shadow: 0 3px 14px rgba(0, 0, 0, 0.45);
+ color: #e2e8f0;
+ border-color: rgba(71, 85, 105, 0.4);
+ background: linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.85));
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05);
+}
+
+[data-theme="dark"] .nav-user-inline:hover {
+ border-color: rgba(100, 116, 139, 0.5);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.nav-user-inline__icon {
diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx
index 328368f..bed2bf1 100644
--- a/src/components/AudioPlayer.jsx
+++ b/src/components/AudioPlayer.jsx
@@ -749,7 +749,7 @@ export default function Player({ user }) {
{formatTime(elapsedTime)}
-
{formatTime(trackDuration - elapsedTime)}
+
-{formatTime(trackDuration - elapsedTime)}
diff --git a/src/components/DiscordLogs.jsx b/src/components/DiscordLogs.jsx
index 5b489bd..39c286d 100644
--- a/src/components/DiscordLogs.jsx
+++ b/src/components/DiscordLogs.jsx
@@ -104,7 +104,7 @@ function formatTimestamp(timestamp, format = 'full') {
const isToday = date.toDateString() === now.toDateString();
const isYesterday = new Date(now - 86400000).toDateString() === date.toDateString();
- const time = date.toLocaleTimeString('en-US', {
+ const time = date.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true
@@ -115,7 +115,7 @@ function formatTimestamp(timestamp, format = 'full') {
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
- return date.toLocaleDateString('en-US', {
+ return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
@@ -127,7 +127,7 @@ function formatTimestamp(timestamp, format = 'full') {
*/
function formatDateDivider(timestamp) {
const date = new Date(timestamp);
- return date.toLocaleDateString('en-US', {
+ return date.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
@@ -135,6 +135,29 @@ function formatDateDivider(timestamp) {
});
}
+/**
+ * Decode HTML entities safely. Uses the DOM if available (client-side), otherwise
+ * falls back to a robust regex for SSR environments.
+ */
+function decodeHtmlEntities(str) {
+ if (!str) return str;
+ try {
+ if (typeof document !== 'undefined') {
+ const tx = document.createElement('textarea');
+ tx.innerHTML = str;
+ return tx.value;
+ }
+ } catch (e) {
+ // fall through to fallback
+ }
+
+ return str.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g, (m, e) => {
+ if (e[0] === '#') return e[1] === 'x' ? String.fromCharCode(parseInt(e.slice(2), 16)) : String.fromCharCode(parseInt(e.slice(1), 10));
+ const map = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', rsquo: '’', lsquo: '‘', hellip: '…', rdquo: '”', ldquo: '“' };
+ return map[e] || m;
+ });
+}
+
/**
* Parse archived messages from #dds-archive channel
* These messages were re-sent by a bot with this format:
@@ -389,28 +412,41 @@ function resolveArchivedUser(archivedUsername, usersMap, members) {
* @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 {Object} options.rolesMap - Map of role IDs to role objects { name, color }
* @param {Function} options.onChannelClick - Callback when channel is clicked
*/
function parseDiscordMarkdown(text, options = {}) {
if (!text) return '';
- const { channelMap = new Map(), usersMap = {}, onChannelClick } = options;
+ // Normalize HTML entities that sometimes make it into messages/embed fields
+ // We decode before we escape so strings like "A & B" become "A & B"
+ // and avoid double-encoding when we later run an escape pass.
+
+ const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), onChannelClick } = options;
+
+ // Normalize entities then escape HTML to avoid XSS while ensuring
+ // already-encoded entities don't become double-encoded in the UI.
+ const normalized = decodeHtmlEntities(text);
// Escape HTML first
- let parsed = text
+ let parsed = normalized
.replace(/&/g, '&')
.replace(//g, '>');
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
// Must be processed first to prevent other formatting inside code
+ // Don't trim - preserve whitespace for ASCII art
parsed = parsed.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => {
- return `
${code.trim()}`;
+ // Only trim trailing newline, preserve all other whitespace
+ const trimmedCode = code.replace(/\n$/, '');
+ return `
${trimmedCode}`;
});
// 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) => {
@@ -558,7 +594,13 @@ function parseDiscordMarkdown(text, options = {}) {
});
// Role mentions (<@&123456789>)
- parsed = parsed.replace(/<@&(\d+)>/g, '
@role ');
+ parsed = parsed.replace(/<@&(\d+)>/g, (_, roleId) => {
+ const role = rolesMap?.get?.(roleId) || rolesMap?.[roleId];
+ const roleName = role?.name || 'role';
+ const roleColor = role?.color || null;
+ const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
+ return `
@${roleName} `;
+ });
// Slash command mentions ()
parsed = parsed.replace(/<\/([^:]+):(\d+)>/g, '
/$1 ');
@@ -709,14 +751,14 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
YouTube
{preview.title && (
- {preview.title}
+ {decodeHtmlEntities(preview.title)}
)}
VIDEO
{preview.siteName && (
-
{preview.siteName}
+
{decodeHtmlEntities(preview.siteName)}
)}
{preview.title && (
- {preview.title}
+ {decodeHtmlEntities(preview.title)}
)}
{preview.description && (
- {preview.description.length > 300
- ? preview.description.slice(0, 300) + '...'
- : preview.description}
+ {(() => {
+ const d = decodeHtmlEntities(preview.description);
+ return d.length > 300 ? d.slice(0, 300) + '...' : d;
+ })()}
)}
@@ -919,7 +962,10 @@ const DiscordMessage = memo(function DiscordMessage({
onChannelSelect,
channelName,
onReactionClick,
- onContextMenu
+ onPollVoterClick,
+ onContextMenu,
+ isSearchResult,
+ onJumpToMessage
}) {
const {
id,
@@ -930,6 +976,7 @@ const DiscordMessage = memo(function DiscordMessage({
embeds = [],
stickers = [],
reactions = [],
+ poll = null,
referenced_message,
type
} = message;
@@ -954,12 +1001,44 @@ const DiscordMessage = memo(function DiscordMessage({
const urls = useMemo(() => extractUrls(displayContent), [displayContent]);
// Filter URLs that don't already have embeds
+ // For YouTube, compare video IDs since URLs can vary (youtu.be vs youtube.com, with/without playlist)
const urlsToPreview = useMemo(() => {
const embedUrls = new Set(embeds?.map(e => e.url).filter(Boolean));
- return urls.filter(url => !embedUrls.has(url));
+
+ // Extract YouTube video IDs from embeds
+ const embedYouTubeIds = new Set();
+ embeds?.forEach(e => {
+ if (e.url) {
+ const ytId = getYouTubeId(e.url);
+ if (ytId) embedYouTubeIds.add(ytId);
+ }
+ // Also check video URL for YouTube embeds
+ if (e.video?.url) {
+ const videoYtId = getYouTubeId(e.video.url);
+ if (videoYtId) embedYouTubeIds.add(videoYtId);
+ }
+ });
+
+ return urls.filter(url => {
+ // Skip if exact URL match
+ if (embedUrls.has(url)) return false;
+
+ // For YouTube URLs, skip if video ID already embedded
+ const ytId = getYouTubeId(url);
+ if (ytId && embedYouTubeIds.has(ytId)) return false;
+
+ return true;
+ });
}, [urls, embeds]);
- const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap }), [displayContent, channelMap, usersMap]);
+ // Build rolesMap from members data for role mention parsing
+ const rolesMap = useMemo(() => {
+ const map = new Map();
+ members?.roles?.forEach(role => map.set(role.id, role));
+ return map;
+ }, [members?.roles]);
+
+ const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap }), [displayContent, channelMap, usersMap, rolesMap]);
// Handle channel link clicks
const handleContentClick = useCallback((e) => {
@@ -988,7 +1067,79 @@ const DiscordMessage = memo(function DiscordMessage({
const getInitial = (name) => (name || 'U')[0].toUpperCase();
// System messages (join, boost, etc.)
- if (type && type !== 0 && type !== 19) {
+ // Type 0 = default, 19 = reply, 20 = chat input command, 23 = context menu command
+ // Types 20 and 23 are app/bot command messages and should render normally
+ if (type && type !== 0 && type !== 19 && type !== 20 && type !== 23) {
+ // Special handling for poll result system messages (type 46)
+ if (type === 46) {
+ // Find the poll_result embed
+ const pollResultEmbed = embeds?.find(e => e.type === 'poll_result');
+ let pollFields = {};
+ if (pollResultEmbed && pollResultEmbed.fields) {
+ for (const field of pollResultEmbed.fields) {
+ pollFields[field.name] = field.value;
+ }
+ }
+ // Get referenced poll message (should have poll info)
+ const pollMessage = referenced_message;
+ // Winner info
+ const winnerText = pollFields['victor_answer_text'] || '';
+ const winnerEmoji = pollFields['victor_answer_emoji_name'] || '';
+ const winnerVotes = parseInt(pollFields['victor_answer_votes'] || '0', 10);
+ const totalVotes = parseInt(pollFields['total_votes'] || '0', 10);
+ const percent = totalVotes > 0 ? Math.round((winnerVotes / totalVotes) * 100) : 0;
+ const pollTitle = pollFields['poll_question_text'] || (pollMessage?.poll?.question_text) || '';
+ // Author of the poll (from referenced message)
+ const pollAuthor = pollMessage?.author;
+ // Handler for View Poll button
+ const handleViewPoll = () => {
+ if (onJumpToMessage && pollMessage?.id) {
+ onJumpToMessage(pollMessage.id);
+ }
+ };
+ return (
+
+
+
+
+
+ {pollAuthor?.displayName || pollAuthor?.username || 'Unknown'}
+
+ ’s poll {pollTitle} has closed
+
+
+
+
+
+
{winnerEmoji}
+
{winnerText}
+
+
+
+
+
+
+
+ {percent}%
+ {totalVotes} vote{totalVotes !== 1 ? 's' : ''}
+
+
+
+ View Poll
+
+
+
{formatTimestamp(displayTimestamp)}
+
+ );
+ }
+ // Default system message rendering
return (
@@ -1026,10 +1177,19 @@ const DiscordMessage = memo(function DiscordMessage({
{referenced_message.author?.displayName || referenced_message.author?.username || 'Unknown'}
-
- {referenced_message.content?.slice(0, 100) || 'Click to see attachment'}
- {referenced_message.content?.length > 100 ? '...' : ''}
-
+ 100
+ ? referenced_message.content.slice(0, 100) + '...'
+ : referenced_message.content,
+ { channelMap, usersMap, rolesMap }
+ )
+ : 'Click to see attachment'
+ }}
+ />
)}
@@ -1078,6 +1238,21 @@ const DiscordMessage = memo(function DiscordMessage({
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown User'}
{formatTimestamp(displayTimestamp)}
+ {isSearchResult && onJumpToMessage && (
+
{
+ e.stopPropagation();
+ onJumpToMessage(id);
+ }}
+ title="Jump to message"
+ >
+
+
+
+ Jump
+
+ )}
)}
@@ -1121,6 +1296,97 @@ const DiscordMessage = memo(function DiscordMessage({
)}
+ {/* Poll */}
+ {poll && (
+
+
+ {poll.question.emoji && (
+ poll.question.emoji.id ? (
+
+ ) : (
+
{poll.question.emoji.name}
+ )
+ )}
+
{poll.question.text}
+ {poll.allowMultiselect && (
+
Select multiple
+ )}
+
+
+ {poll.answers.map((answer) => {
+ const percentage = poll.totalVotes > 0
+ ? Math.round((answer.voteCount / poll.totalVotes) * 100)
+ : 0;
+ return (
+
onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}>
+
+
+ {answer.emoji && (
+ answer.emoji.id ? (
+
+ ) : (
+
{answer.emoji.name}
+ )
+ )}
+
{answer.text}
+
+ {answer.voteCount} ({percentage}%)
+
+
+ {answer.voters?.length > 0 && (
+
+ {answer.voters.slice(0, 10).map((voter) => (
+
+ {voter.avatar ? (
+
+ ) : (
+
{(voter.displayName || voter.username || 'U')[0].toUpperCase()}
+ )}
+
+ ))}
+ {answer.voters.length > 10 && (
+
+ +{answer.voters.length - 10}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+
+ {poll.totalVotes} {poll.totalVotes === 1 ? 'vote' : 'votes'}
+
+ {poll.isFinalized && (
+ Poll ended
+ )}
+ {!poll.isFinalized && poll.expiry && (
+
+ Ends {new Date(poll.expiry).toLocaleDateString()}
+
+ )}
+
+
+ )}
+
{/* Original embeds from Discord */}
{embeds?.map((embed, idx) => (
- {embed.title}
+ {decodeHtmlEntities(embed.title)}
) : (
-
{embed.title}
+
{decodeHtmlEntities(embed.title)}
)
)}
{embed.description && (
)}
@@ -1175,7 +1441,7 @@ const DiscordMessage = memo(function DiscordMessage({
)}
@@ -1197,14 +1463,25 @@ const DiscordMessage = memo(function DiscordMessage({
)}
{embed.video?.url && (
-
+ // Check if it's a YouTube embed URL - use iframe instead of video
+ embed.video.url.includes('youtube.com/embed/') || embed.video.url.includes('youtu.be') ? (
+
+ ) : (
+
+ )
)}
{embed.footer && (
{embed.footer.icon_url && (
)}
-
{embed.footer.text}
+
{decodeHtmlEntities(embed.footer.text)}
{embed.timestamp && (
<>
•
@@ -1289,9 +1566,18 @@ export default function DiscordLogs() {
const [previewCache, setPreviewCache] = useState({});
const [imageModal, setImageModal] = useState(null); // { url, alt }
const [reactionPopup, setReactionPopup] = useState(null); // { x, y, emoji, users, loading }
+ const [pollVoterPopup, setPollVoterPopup] = useState(null); // { x, y, answer, voters }
const [contextMenu, setContextMenu] = useState(null); // { x, y, messageId, content }
const [channelContextMenu, setChannelContextMenu] = useState(null); // { x, y, channel }
const [topicExpanded, setTopicExpanded] = useState(false); // Show full channel topic
+ const [refetchCounter, setRefetchCounter] = useState(0); // Counter to force re-fetch messages
+ const lastPollTimeRef = useRef(new Date().toISOString()); // Track last poll time for edit detection
+
+ // Collapsible categories in sidebar
+ const [collapsedCategories, setCollapsedCategories] = useState({});
+ const handleCategoryToggle = useCallback((catName) => {
+ setCollapsedCategories(prev => ({ ...prev, [catName]: !prev[catName] }));
+ }, []);
// Debounced server-side search
useEffect(() => {
@@ -1383,6 +1669,26 @@ export default function DiscordLogs() {
setReactionPopup(null);
}, []);
+ // Handle poll voter click - show voters popup
+ const handlePollVoterClick = useCallback((e, answer) => {
+ e.stopPropagation(); // Prevent click from bubbling up to handleClickOutside
+ if (!answer?.voters?.length) return;
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = rect.left + rect.width / 2;
+ const y = rect.top;
+ setPollVoterPopup({
+ x,
+ y,
+ answer,
+ voters: answer.voters,
+ });
+ }, []);
+
+ // Close poll voter popup
+ const closePollVoterPopup = useCallback(() => {
+ setPollVoterPopup(null);
+ }, []);
+
// Handle message context menu (right-click)
const handleMessageContextMenu = useCallback((e, messageId, content) => {
e.preventDefault();
@@ -1416,6 +1722,35 @@ export default function DiscordLogs() {
setContextMenu(null);
}, [contextMenu]);
+ // Jump to a specific message (used from search results and poll result view)
+ const jumpToMessage = useCallback((messageId) => {
+ if (!messageId) return;
+
+ // First check if the message is already loaded
+ const existingMessage = messages.find(m => m.id === messageId);
+ if (existingMessage) {
+ // Message is already loaded, just scroll to it
+ const element = document.getElementById(`message-${messageId}`);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ // Add a brief highlight effect
+ element.classList.add('discord-message-highlight');
+ setTimeout(() => element.classList.remove('discord-message-highlight'), 2000);
+ return;
+ }
+ }
+
+ // Message not loaded, need to fetch around it
+ // Clear search to exit search mode
+ setSearchQuery('');
+ setSearchResults(null);
+ // Set the target message ID for the fetch effect
+ pendingTargetMessageRef.current = messageId;
+ // Trigger a re-fetch by incrementing the counter
+ setRefetchCounter(c => c + 1);
+ setLoadingMessages(true);
+ }, [messages]);
+
// Handle channel context menu (right-click)
const handleChannelContextMenu = useCallback((e, channel) => {
e.preventDefault();
@@ -1454,6 +1789,7 @@ export default function DiscordLogs() {
if (e.key === 'Escape') {
if (imageModal) closeImageModal();
if (reactionPopup) closeReactionPopup();
+ if (pollVoterPopup) closePollVoterPopup();
if (contextMenu) setContextMenu(null);
if (channelContextMenu) setChannelContextMenu(null);
}
@@ -1462,6 +1798,9 @@ export default function DiscordLogs() {
if (reactionPopup && !e.target.closest('.discord-reaction-popup')) {
closeReactionPopup();
}
+ if (pollVoterPopup && !e.target.closest('.discord-reaction-popup')) {
+ closePollVoterPopup();
+ }
if (contextMenu && !e.target.closest('.discord-context-menu')) {
setContextMenu(null);
}
@@ -1475,7 +1814,7 @@ export default function DiscordLogs() {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('click', handleClickOutside);
};
- }, [imageModal, reactionPopup, contextMenu, closeImageModal, closeReactionPopup]);
+ }, [imageModal, reactionPopup, pollVoterPopup, contextMenu, channelContextMenu, closeImageModal, closeReactionPopup, closePollVoterPopup]);
// Update URL hash when guild/channel changes
useEffect(() => {
@@ -1517,6 +1856,13 @@ export default function DiscordLogs() {
return map;
}, [channels]);
+ // Create roles lookup map for role mentions
+ const rolesMap = useMemo(() => {
+ const map = new Map();
+ members?.roles?.forEach(role => map.set(role.id, role));
+ return map;
+ }, [members?.roles]);
+
// Handle channel selection from mentions
const handleChannelSelect = useCallback((channel) => {
// Find the full channel object with guild info
@@ -1542,9 +1888,10 @@ export default function DiscordLogs() {
if (!response.ok) throw new Error('Failed to fetch channels');
const data = await response.json();
- // Separate categories (type 4) from text channels (type 0)
+ // Separate categories (type 4), text channels (type 0), and threads (types 10, 11, 12)
const categories = {};
const textChannels = [];
+ const threads = [];
data.forEach(channel => {
if (channel.type === 4) {
@@ -1554,11 +1901,24 @@ export default function DiscordLogs() {
position: channel.position,
guildId: channel.guildId,
};
+ } else if (channel.type === 10 || channel.type === 11 || channel.type === 12) {
+ threads.push(channel);
} else {
textChannels.push(channel);
}
});
+ // Create a map of parent channel ID to threads
+ const threadsByParent = {};
+ threads.forEach(thread => {
+ if (thread.parentId) {
+ if (!threadsByParent[thread.parentId]) {
+ threadsByParent[thread.parentId] = [];
+ }
+ threadsByParent[thread.parentId].push(thread);
+ }
+ });
+
// Group channels by guild
const byGuild = {};
const guildMap = {};
@@ -1576,6 +1936,9 @@ export default function DiscordLogs() {
const categoryName = channel.parentId ? categories[channel.parentId]?.name : null;
const categoryPosition = channel.parentId ? categories[channel.parentId]?.position : -1;
+ // Get threads for this channel
+ const channelThreads = threadsByParent[channel.id] || [];
+
byGuild[guildId].push({
id: channel.id,
name: channel.name,
@@ -1587,6 +1950,16 @@ export default function DiscordLogs() {
categoryPosition,
guild: guildMap[guildId],
messageCount: channel.messageCount || 0,
+ threads: channelThreads.map(t => ({
+ id: t.id,
+ name: t.name,
+ type: t.type,
+ messageCount: t.messageCount || 0,
+ parentId: t.parentId,
+ guildId: t.guildId,
+ guildName: t.guildName,
+ guildIcon: t.guildIcon,
+ })),
});
});
@@ -1720,6 +2093,33 @@ export default function DiscordLogs() {
const messagesData = data.messages || data;
const usersData = data.users || {};
+ // If we were looking for a specific message but got no results,
+ // fall back to loading the latest messages
+ if (targetMessageId && messagesData.length === 0) {
+ console.warn(`Target message ${targetMessageId} not found, loading latest messages`);
+ pendingTargetMessageRef.current = null;
+ const fallbackResponse = await authFetch(`/api/discord/messages?channelId=${selectedChannel.id}&limit=50`);
+ if (fallbackResponse.ok) {
+ const fallbackData = await fallbackResponse.json();
+ const fallbackMessages = fallbackData.messages || fallbackData;
+ const fallbackUsers = fallbackData.users || {};
+ const normalizedFallback = fallbackMessages.map(msg => ({
+ ...msg,
+ referenced_message: msg.referencedMessage || msg.referenced_message,
+ attachments: (msg.attachments || []).map(att => ({
+ ...att,
+ content_type: att.contentType || att.content_type,
+ })),
+ }));
+ setMessages(normalizedFallback.reverse());
+ setUsersMap(fallbackUsers);
+ setHasMoreMessages(fallbackMessages.length === 50);
+ scrollToBottomRef.current = true;
+ lastPollTimeRef.current = new Date().toISOString();
+ return;
+ }
+ }
+
// Normalize field names from API to component expectations
const normalizedMessages = messagesData.map(msg => ({
...msg,
@@ -1740,6 +2140,9 @@ export default function DiscordLogs() {
setUsersMap(usersData);
setHasMoreMessages(messagesData.length === 50);
+ // Reset poll time for edit detection
+ lastPollTimeRef.current = new Date().toISOString();
+
// Set flag to scroll after render (handled by useLayoutEffect)
if (targetMessageId) {
// Keep the target in pendingTargetMessageRef for useLayoutEffect
@@ -1755,9 +2158,9 @@ export default function DiscordLogs() {
}
fetchMessages();
- // Only re-fetch when channel ID changes
+ // Re-fetch when channel ID changes or refetchCounter is incremented (for jump-to-message)
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedChannel?.id]);
+ }, [selectedChannel?.id, refetchCounter]);
// Handle scroll positioning after messages are rendered
useLayoutEffect(() => {
@@ -1858,7 +2261,7 @@ export default function DiscordLogs() {
return () => container.removeEventListener('scroll', handleScroll);
}, [hasMoreMessages, loadingMore, loadMoreMessages, searchQuery, searchResults]);
- // Poll for new messages every 5 seconds
+ // Poll for new messages and edits every 5 seconds
useEffect(() => {
if (!selectedChannel || loadingMessages || messages.length === 0) return;
// Don't poll when viewing search results - it would add messages that aren't in search
@@ -1866,39 +2269,99 @@ export default function DiscordLogs() {
const pollInterval = setInterval(async () => {
try {
+ const pollStartTime = new Date().toISOString();
+
// Get the newest message ID (messages are in ASC order, so last = newest)
const newestMessage = messages[messages.length - 1];
- const response = await authFetch(
+
+ // Fetch new messages after newest
+ const newMsgsResponse = await authFetch(
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}`
);
- if (!response.ok) return;
- const data = await response.json();
- const messagesData = data.messages || data;
- const usersData = data.users || {};
+ let newMessages = [];
+ let newUsersData = {};
- if (messagesData.length === 0) return;
+ if (newMsgsResponse.ok) {
+ const data = await newMsgsResponse.json();
+ const messagesData = data.messages || data;
+ newUsersData = data.users || {};
- const normalizedMessages = messagesData.map(msg => ({
- ...msg,
- referenced_message: msg.referencedMessage || msg.referenced_message,
- attachments: (msg.attachments || []).map(att => ({
- ...att,
- content_type: att.contentType || att.content_type,
- })),
- }));
+ if (messagesData.length > 0) {
+ newMessages = messagesData.map(msg => ({
+ ...msg,
+ referenced_message: msg.referencedMessage || msg.referenced_message,
+ attachments: (msg.attachments || []).map(att => ({
+ ...att,
+ content_type: att.contentType || att.content_type,
+ })),
+ }));
+ }
+ }
+
+ // Fetch messages edited since last poll
+ const editedResponse = await authFetch(
+ `/api/discord/messages?channelId=${selectedChannel.id}&limit=100&editedSince=${encodeURIComponent(lastPollTimeRef.current)}`
+ );
+
+ let editedMessages = [];
+ if (editedResponse.ok) {
+ const editedData = await editedResponse.json();
+ const editedMessagesData = editedData.messages || editedData;
+ const editedUsersData = editedData.users || {};
+ newUsersData = { ...newUsersData, ...editedUsersData };
+
+ editedMessages = editedMessagesData.map(msg => ({
+ ...msg,
+ referenced_message: msg.referencedMessage || msg.referenced_message,
+ attachments: (msg.attachments || []).map(att => ({
+ ...att,
+ content_type: att.contentType || att.content_type,
+ })),
+ }));
+ }
+
+ // Update last poll time for next iteration
+ lastPollTimeRef.current = pollStartTime;
// Check if user is scrolled near bottom before adding new messages
const container = messagesContainerRef.current;
const isNearBottom = container &&
(container.scrollHeight - container.scrollTop - container.clientHeight < 100);
- // Append new messages (already in ASC order from API)
- setMessages(prev => [...prev, ...normalizedMessages]);
- setUsersMap(prev => ({ ...prev, ...usersData }));
+ // Only update state if there are changes
+ if (newMessages.length > 0 || editedMessages.length > 0) {
+ setMessages(prev => {
+ // Create a map of existing messages by ID
+ const msgMap = new Map(prev.map(m => [m.id, m]));
- // Auto-scroll to bottom if user was already near bottom
- if (isNearBottom && container) {
+ // Update with edited messages (overwrite existing)
+ for (const msg of editedMessages) {
+ if (msgMap.has(msg.id)) {
+ msgMap.set(msg.id, msg);
+ }
+ }
+
+ // Append new messages
+ for (const msg of newMessages) {
+ if (!msgMap.has(msg.id)) {
+ msgMap.set(msg.id, msg);
+ }
+ }
+
+ // Convert back to array and sort by ID (snowflake = timestamp order)
+ return Array.from(msgMap.values()).sort((a, b) => {
+ if (a.id < b.id) return -1;
+ if (a.id > b.id) return 1;
+ return 0;
+ });
+ });
+
+ setUsersMap(prev => ({ ...prev, ...newUsersData }));
+ }
+
+ // Auto-scroll to bottom if user was already near bottom and there are new messages
+ if (isNearBottom && container && newMessages.length > 0) {
setTimeout(() => {
container.scrollTop = container.scrollHeight;
}, 50);
@@ -1909,9 +2372,7 @@ export default function DiscordLogs() {
}, 5000);
return () => clearInterval(pollInterval);
- }, [selectedChannel, loadingMessages, messages, searchQuery, searchResults]);
-
- // Poll for channel/guild updates every 5 seconds
+ }, [selectedChannel, loadingMessages, messages, searchQuery, searchResults]); // Poll for channel/guild updates every 5 seconds
useEffect(() => {
if (loading) return; // Don't poll during initial load
@@ -1921,9 +2382,10 @@ export default function DiscordLogs() {
if (!response.ok) return;
const data = await response.json();
- // Separate categories from text channels
+ // Separate categories (type 4), text channels (type 0), and threads (types 10, 11, 12)
const categories = {};
const textChannels = [];
+ const threads = [];
data.forEach(channel => {
if (channel.type === 4) {
@@ -1933,11 +2395,24 @@ export default function DiscordLogs() {
position: channel.position,
guildId: channel.guildId,
};
+ } else if (channel.type === 10 || channel.type === 11 || channel.type === 12) {
+ threads.push(channel);
} else {
textChannels.push(channel);
}
});
+ // Create a map of parent channel ID to threads
+ const threadsByParent = {};
+ threads.forEach(thread => {
+ if (thread.parentId) {
+ if (!threadsByParent[thread.parentId]) {
+ threadsByParent[thread.parentId] = [];
+ }
+ threadsByParent[thread.parentId].push(thread);
+ }
+ });
+
// Rebuild guild/channel maps
const byGuild = {};
const guildMap = {};
@@ -1955,6 +2430,9 @@ export default function DiscordLogs() {
const categoryName = channel.parentId ? categories[channel.parentId]?.name : null;
const categoryPosition = channel.parentId ? categories[channel.parentId]?.position : -1;
+ // Get threads for this channel
+ const channelThreads = threadsByParent[channel.id] || [];
+
byGuild[guildId].push({
id: channel.id,
name: channel.name,
@@ -1966,6 +2444,16 @@ export default function DiscordLogs() {
categoryPosition,
guild: guildMap[guildId],
messageCount: channel.messageCount || 0,
+ threads: channelThreads.map(t => ({
+ id: t.id,
+ name: t.name,
+ type: t.type,
+ messageCount: t.messageCount || 0,
+ parentId: t.parentId,
+ guildId: t.guildId,
+ guildName: t.guildName,
+ guildIcon: t.guildIcon,
+ })),
});
});
@@ -2170,9 +2658,16 @@ export default function DiscordLogs() {
{selectedChannel.guild?.name || 'Discord Archive'}
-
-
-
+ {/* Show thread icon for thread types (10, 11, 12), otherwise channel hash */}
+ {selectedChannel.type === 10 || selectedChannel.type === 11 || selectedChannel.type === 12 ? (
+
+
+
+ ) : (
+
+
+
+ )}
{selectedChannel.name}
{selectedChannel.topic && (
<>
@@ -2182,7 +2677,7 @@ export default function DiscordLogs() {
onClick={() => setTopicExpanded(!topicExpanded)}
title={topicExpanded ? 'Click to collapse' : 'Click to expand'}
dangerouslySetInnerHTML={{
- __html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap })
+ __html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap })
}}
/>
>
@@ -2228,7 +2723,7 @@ export default function DiscordLogs() {
{/* Sidebar with Guild and Channel selector */}
- {/* Guild tabs */}
+ {/* Guild tabs - compact vertical strip */}
{guilds.length > 1 && (
{guilds.map((guild) => (
@@ -2263,45 +2758,76 @@ export default function DiscordLogs() {
)}
{/* Channel list for selected guild */}
- {selectedGuild && channelsByGuild[selectedGuild.id]?.length > 0 && (
-
-
- {selectedGuild.name}
-
- {(() => {
- let lastCategory = null;
- // Filter out channels with no messages (no access)
- const accessibleChannels = channelsByGuild[selectedGuild.id].filter(c => c.messageCount > 0);
- return accessibleChannels.map((channel) => {
- const showCategoryHeader = channel.categoryName !== lastCategory;
- lastCategory = channel.categoryName;
- return (
-
- {showCategoryHeader && channel.categoryName && (
-
-
-
-
- {channel.categoryName}
-
- )}
- setSelectedChannel(channel)}
- onContextMenu={(e) => handleChannelContextMenu(e, channel)}
- title={channel.name}
- >
-
-
-
- {channel.name}
-
-
+
+ {selectedGuild && channelsByGuild[selectedGuild.id]?.length > 0 && (
+
+
+ {selectedGuild.name}
+
+ {/* Collapsible categories state and handler at top level */}
+ {/* ...existing code... */}
+ {(() => {
+ let lastCategory = null;
+ // Filter out channels with no messages (no access), but include if they have threads with messages
+ const accessibleChannels = channelsByGuild[selectedGuild.id].filter(c =>
+ c.messageCount > 0 || c.threads?.some(t => t.messageCount > 0)
);
- });
- })()}
-
- )}
+ return accessibleChannels.map((channel) => {
+ const showCategoryHeader = channel.categoryName !== lastCategory;
+ lastCategory = channel.categoryName;
+ // Filter threads that have messages
+ const accessibleThreads = channel.threads?.filter(t => t.messageCount > 0) || [];
+ const isCollapsed = channel.categoryName && collapsedCategories[channel.categoryName];
+ return (
+
+ {showCategoryHeader && channel.categoryName && (
+ handleCategoryToggle(channel.categoryName)} style={{ cursor: 'pointer', userSelect: 'none' }}>
+
+
+
+ {channel.categoryName}
+
+ )}
+ {!isCollapsed && channel.messageCount > 0 && (
+ setSelectedChannel(channel)}
+ onContextMenu={(e) => handleChannelContextMenu(e, channel)}
+ title={channel.name}
+ >
+
+
+
+ {channel.name}
+
+ )}
+ {/* Threads under this channel */}
+ {!isCollapsed && accessibleThreads.map((thread) => (
+ setSelectedChannel({
+ ...thread,
+ categoryName: channel.categoryName,
+ categoryPosition: channel.categoryPosition,
+ guild: channel.guild,
+ })}
+ onContextMenu={(e) => handleChannelContextMenu(e, thread)}
+ title={thread.name}
+ >
+
+
+
+ {thread.name}
+
+ ))}
+
+ );
+ });
+ })()}
+
+ )}
+
{/* Content area: search + messages */}
@@ -2368,6 +2894,9 @@ export default function DiscordLogs() {
);
}
+ // Check if we're showing search results
+ const isSearchMode = searchQuery.trim().length >= 2 && searchResults !== null;
+
return (
{group.messages.map((message, msgIdx) => (
@@ -2383,7 +2912,10 @@ export default function DiscordLogs() {
onChannelSelect={handleChannelSelect}
channelName={selectedChannel?.name}
onReactionClick={handleReactionClick}
+ onPollVoterClick={handlePollVoterClick}
onContextMenu={handleMessageContextMenu}
+ isSearchResult={isSearchMode}
+ onJumpToMessage={jumpToMessage}
/>
))}
@@ -2516,6 +3048,43 @@ export default function DiscordLogs() {
)}
+ {/* Poll Voter Popup - same style as reaction popup */}
+ {pollVoterPopup && (
+
e.stopPropagation()}
+ >
+
+
+
+
+
{pollVoterPopup.voters?.length || 0}
+
+
+ {pollVoterPopup.voters?.length > 0 ? (
+ pollVoterPopup.voters.map((voter) => (
+
+
+
{voter.displayName || voter.username}
+
+ ))
+ ) : (
+
No voters found
+ )}
+
+
+ )}
+
{/* Message Context Menu */}
{contextMenu && (
{
authFetch(`${API_URL}/lighting/state`)
@@ -26,38 +28,44 @@ export default function Lighting() {
setState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
}, []);
- const handleColorChange = (color) => {
- if (import.meta.env.DEV) console.debug('Handle color change:', color);
- const { r, g, b } = color.rgb;
- updateLighting({
- ...state,
- red: r,
- green: g,
- blue: b,
- });
- };
+ // Cleanup debounce on unmount
+ useEffect(() => {
+ return () => {
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ };
+ }, []);
- const debounceRef = useRef();
-
- const updateLighting = (newState) => {
+ // Consolidated debounced update function
+ const updateLighting = useCallback((newState) => {
setState(newState);
+ setPending(true);
+ setError('');
+ setSuccess(false);
// Clear any pending timeout
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
- // Set new timeout for API call
+ // Set new timeout for API call (250ms for smoother drag experience)
debounceRef.current = setTimeout(() => {
authFetch(`${API_URL}/lighting/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newState),
})
- .then(() => setSuccess(true))
- .catch(() => setError('Failed to update lighting state'));
- }, 100); // 100ms debounce for 25 req/2s rate limit
- };
+ .then(() => {
+ setSuccess(true);
+ setPending(false);
+ })
+ .catch(() => {
+ setError('Failed to update lighting state');
+ setPending(false);
+ });
+ }, 250);
+ }, []);
const handleSubmit = (e) => {
e.preventDefault();
@@ -129,11 +137,13 @@ export default function Lighting() {
(v ?? 100) / 100 // value: 0-100 -> 0-1, default to 1 if undefined
);
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', rgb);
+ // Auto power on when changing color
updateLighting({
...state,
red: rgb.red,
green: rgb.green,
- blue: rgb.blue
+ blue: rgb.blue,
+ power: state.power === 'off' ? 'on' : state.power
});
}}
width={180}
@@ -151,20 +161,18 @@ export default function Lighting() {
value={state.brightness}
onChange={e => {
const newValue = Number(e.target.value);
- const newState = {
+ // Auto power off at 0, auto power on when > 0
+ let newPower = state.power;
+ if (newValue === 0) {
+ newPower = 'off';
+ } else if (state.power === 'off' && newValue > 0) {
+ newPower = 'on';
+ }
+ updateLighting({
...state,
brightness: newValue,
- power: newValue === 0 ? 'off' : state.power
- };
- setState(newState);
-
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
-
- debounceRef.current = setTimeout(() => {
- updateLighting(newState);
- }, 100); // 100ms debounce for 25 req/2s rate limit
+ power: newPower
+ });
}}
className="w-full max-w-xs accent-yellow-500"
/>
@@ -186,7 +194,8 @@ export default function Lighting() {
{error &&
{error}
}
- {success &&
Updated!
}
+ {success && !pending &&
Updated!
}
+ {pending &&
Syncing...
}
{loading &&
Loading...
}
diff --git a/src/components/Login.jsx b/src/components/Login.jsx
index 93e97db..b7fc6bd 100644
--- a/src/components/Login.jsx
+++ b/src/components/Login.jsx
@@ -103,7 +103,7 @@ export default function LoginPage({ loggedIn = false }) {
You're already logged in
-
You do not have permission to access this resource.
+
But you do not have permission to access this resource.
If you feel you have received this message in error, scream at codey.
diff --git a/src/components/LyricSearch.jsx b/src/components/LyricSearch.jsx
index 395e37b..b86cda1 100644
--- a/src/components/LyricSearch.jsx
+++ b/src/components/LyricSearch.jsx
@@ -8,37 +8,41 @@ import React, {
useCallback,
} from "react";
import { toast } from 'react-toastify';
+import DOMPurify from 'isomorphic-dompurify';
import Box from '@mui/joy/Box';
import Button from "@mui/joy/Button";
import IconButton from "@mui/joy/IconButton";
-import Checkbox from "@mui/joy/Checkbox";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import LinkIcon from '@mui/icons-material/Link';
+import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
import { API_URL } from '../config';
+// Sanitize HTML from external sources to prevent XSS
+const sanitizeHtml = (html) => {
+ if (!html) return '';
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: ['br', 'p', 'b', 'i', 'em', 'strong', 'span', 'div'],
+ ALLOWED_ATTR: ['class'],
+ });
+};
+
export default function LyricSearch() {
const [showLyrics, setShowLyrics] = useState(false);
return (
-
- Lyric Search
-
-
+
+ Lyric Search
+
+
);
}
@@ -53,6 +57,10 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const [lyricsResult, setLyricsResult] = useState(null);
const [textSize, setTextSize] = useState("normal");
const [inputStatus, setInputStatus] = useState("hint");
+ const [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration }
+ const [youtubeLoading, setYoutubeLoading] = useState(false);
+ const [highlightedVerse, setHighlightedVerse] = useState(null);
+ const [isLyricsVisible, setIsLyricsVisible] = useState(false);
const searchToastRef = useRef(null);
const autoCompleteRef = useRef(null);
const autoCompleteInputRef = useRef(null);
@@ -122,6 +130,39 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
setSuggestions(json);
}, []);
+ // Fetch YouTube video for the song
+ const fetchYouTubeVideo = useCallback(async (artist, song) => {
+ setYoutubeLoading(true);
+ try {
+ const res = await fetch(`${API_URL}/yt/search`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ t: `${artist} - ${song}` }),
+ });
+ if (!res.ok) {
+ setYoutubeVideo(null);
+ setYoutubeLoading(false);
+ return;
+ }
+ const data = await res.json();
+ if (data?.video_id) {
+ setYoutubeVideo({
+ video_id: data.video_id,
+ title: data.extras?.title || `${artist} - ${song}`,
+ channel: data.extras?.channel || '',
+ duration: data.extras?.duration || '',
+ thumbnail: data.extras?.thumbnails?.[0] || null,
+ });
+ } else {
+ setYoutubeVideo(null);
+ }
+ } catch (err) {
+ console.error('YouTube search failed:', err);
+ setYoutubeVideo(null);
+ }
+ setYoutubeLoading(false);
+ }, []);
+
// Toggle exclusion state for checkboxes
const toggleExclusion = (source) => {
const lower = source.toLowerCase();
@@ -194,10 +235,34 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
evaluateSearchValue(value);
}, [value, evaluateSearchValue]);
- const handleSearch = async (searchValue = value) => {
- if (autoCompleteRef.current) {
- autoCompleteRef.current.hide();
+ // Robustly hide autocomplete panel
+ const hideAutocompletePanel = useCallback(() => {
+ // Method 1: Use PrimeReact API
+ if (autoCompleteRef.current?.hide) {
+ try {
+ autoCompleteRef.current.hide();
+ } catch (e) {
+ // Ignore
+ }
}
+
+ // Method 2: Clear suggestions to ensure panel hides
+ setSuggestions([]);
+
+ // Method 3: Force hide via DOM after a tick (fallback)
+ setTimeout(() => {
+ const panel = document.querySelector('.p-autocomplete-panel');
+ if (panel) {
+ panel.style.display = 'none';
+ panel.style.opacity = '0';
+ panel.style.visibility = 'hidden';
+ panel.style.pointerEvents = 'none';
+ }
+ }, 10);
+ }, []);
+
+ const handleSearch = async (searchValue = value) => {
+ hideAutocompletePanel();
autoCompleteInputRef.current?.blur(); // blur early so the suggestion panel closes immediately
const evaluation = evaluateSearchValue(searchValue);
@@ -210,6 +275,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const { artist, song } = evaluation;
setIsLoading(true);
setLyricsResult(null);
+ setYoutubeVideo(null); // Reset YouTube video
setShowLyrics(false);
const toastId = "lyrics-searching-toast";
@@ -251,12 +317,21 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
setTextSize("normal");
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
+ setHighlightedVerse(null);
+ setIsLyricsVisible(false);
+ // Trigger fade-in animation
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => setIsLyricsVisible(true));
+ });
setShowLyrics(true);
// Update URL hash with search parameters
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
window.history.pushState(null, '', hash);
+ // Search for YouTube video (don't block on this)
+ fetchYouTubeVideo(data.artist, data.song);
+
dismissSearchToast();
toast.success(`Found! (Took ${duration}s)`, {
autoClose: 2500,
@@ -270,6 +345,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
});
} finally {
setIsLoading(false);
+ hideAutocompletePanel();
autoCompleteInputRef.current?.blur();
searchButtonRef.current?.blur();
searchToastRef.current = null;
@@ -279,6 +355,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
+ hideAutocompletePanel();
handleSearch();
}
};
@@ -348,8 +425,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
onShow={handlePanelShow}
placeholder={placeholder}
autoFocus
- style={{ width: '100%', maxWidth: '900px' }}
- inputStyle={{ width: '100%' }}
+ style={{ width: '100%' }}
className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`}
aria-invalid={inputStatus === "error"}
aria-label={`Lyric search input. ${statusTitle}`}
@@ -370,30 +446,31 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
{statusTitle}
-
handleSearch()}
- className="btn"
- ref={searchButtonRef}
- >
- Search
-
-
- Exclude:
-
+
+
handleSearch()}
+ className="search-btn"
+ ref={searchButtonRef}
+ >
+ Search
+
+
+
+ Exclude:
-
+
{isLoading && (
-
+
)}
{lyricsResult && (
-
+
{lyricsResult.artist} - {lyricsResult.song}
@@ -439,10 +516,40 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
>
+ {youtubeLoading && (
+
+
+
+ )}
+ {!youtubeLoading && youtubeVideo && (
+
+
+
+ )}
)}
@@ -460,10 +567,11 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
}));
const verifyExclusions = () => {
- const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]");
- const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
+ const checkboxes = document.querySelectorAll(".exclude-chip");
+ const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
if (checkedCount === 3) {
+ // Reset all by triggering clicks
checkboxes.forEach(cb => cb.click());
if (!toast.isActive("lyrics-exclusion-reset-toast")) {
toast.error("All sources were excluded; exclusions have been reset.",
@@ -473,27 +581,27 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
}
};
- const handleChange = (e) => {
- const newChecked = e.target.checked;
+ const handleClick = () => {
+ const newChecked = !checked;
setChecked(newChecked);
if (props.onToggle) {
- const source = props.label; // Use label as source identifier
+ const source = props.label;
props.onToggle(source);
}
- verifyExclusions();
+ setTimeout(verifyExclusions, 0);
};
return (
-
-
-
+
+ {props.label}
+
);
});
diff --git a/src/components/Memes.jsx b/src/components/Memes.jsx
index dd6efe9..55a915a 100644
--- a/src/components/Memes.jsx
+++ b/src/components/Memes.jsx
@@ -17,7 +17,10 @@ const Memes = () => {
const [hasMore, setHasMore] = useState(true);
const [selectedImage, setSelectedImage] = useState(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
+ const [imageLoading, setImageLoading] = useState({});
const observerRef = useRef();
+ const touchStartRef = useRef(null);
+ const touchEndRef = useRef(null);
const theme = document.documentElement.getAttribute("data-theme")
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
@@ -158,6 +161,43 @@ const Memes = () => {
setSelectedIndex(-1);
}, []);
+ // Touch swipe handlers for mobile navigation
+ const handleTouchStart = useCallback((e) => {
+ touchStartRef.current = e.touches[0].clientX;
+ touchEndRef.current = null;
+ }, []);
+
+ const handleTouchMove = useCallback((e) => {
+ touchEndRef.current = e.touches[0].clientX;
+ }, []);
+
+ const handleTouchEnd = useCallback(() => {
+ if (!touchStartRef.current || !touchEndRef.current) return;
+ const distance = touchStartRef.current - touchEndRef.current;
+ const minSwipeDistance = 50;
+
+ if (Math.abs(distance) > minSwipeDistance) {
+ if (distance > 0) {
+ // Swiped left -> next
+ handleNavigate(1);
+ } else {
+ // Swiped right -> prev
+ handleNavigate(-1);
+ }
+ }
+ touchStartRef.current = null;
+ touchEndRef.current = null;
+ }, [handleNavigate]);
+
+ // Track image loading state
+ const handleImageLoad = useCallback((id) => {
+ setImageLoading(prev => ({ ...prev, [id]: false }));
+ }, []);
+
+ const handleImageLoadStart = useCallback((id) => {
+ setImageLoading(prev => ({ ...prev, [id]: true }));
+ }, []);
+
const handleCopyImage = useCallback(async () => {
if (!selectedImage) return;
try {
@@ -181,6 +221,7 @@ const Memes = () => {
{images.map((img, i) => {
const isLast = i === images.length - 1;
+ const isLoading = imageLoading[img.id] !== false;
return (
{
setSelectedIndex(i);
prefetchImage(images[i + 1]);
}}
- style={{ cursor: 'pointer' }}
+ style={{ cursor: 'pointer', position: 'relative' }}
>
+ {isLoading && (
+
+ )}
handleImageLoad(img.id)}
+ onLoadStart={() => handleImageLoadStart(img.id)}
/>
);
@@ -239,7 +285,12 @@ const Memes = () => {
dismissableMask={true}
>
{selectedImage && (
-
+
-
- {pages.map(({ key, label, href }, i) => {
- return (
-
-
- {label}
-
- {i < pages.length - 1 && / }
-
- );
- })}
-
-
+
+ {pages.map(({ key, label, href }, i) => {
+ const isActive = currentPage === key;
+ return (
+
+
+ {label}
+
+ {i < pages.length - 1 && (
+ /
+ )}
+
+ );
+ })}
+
);
}
diff --git a/src/components/TRip/MediaRequestForm.jsx b/src/components/TRip/MediaRequestForm.jsx
index 519a83e..0e3a531 100644
--- a/src/components/TRip/MediaRequestForm.jsx
+++ b/src/components/TRip/MediaRequestForm.jsx
@@ -6,6 +6,7 @@ import { AutoComplete } from "primereact/autocomplete";
import { authFetch } from "@/utils/authFetch";
import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL, ENVIRONMENT } from "@/config";
+import "./RequestManagement.css";
export default function MediaRequestForm() {
const [type, setType] = useState("artist");
@@ -918,7 +919,7 @@ export default function MediaRequestForm() {
return (
-
+
-
New Request
+
New Request
+
Search for an artist to browse and select tracks for download.
Artist:
diff --git a/src/components/TRip/RequestManagement.css b/src/components/TRip/RequestManagement.css
index 39e3dc2..5981644 100644
--- a/src/components/TRip/RequestManagement.css
+++ b/src/components/TRip/RequestManagement.css
@@ -1,81 +1,442 @@
/* Table and Dark Overrides */
-.p-datatable {
- table-layout: fixed !important;
+.trip-management-container {
+ width: 100%;
}
-.p-datatable td span.truncate {
+
+.trip-management-container .table-wrapper {
+ width: 100%;
+}
+
+.trip-management-container .p-datatable {
+ width: 100% !important;
+ display: block !important;
+}
+
+.trip-management-container .p-datatable-wrapper {
+ width: 100% !important;
+ overflow-x: auto;
+}
+
+.trip-management-container .p-datatable-table {
+ width: 100% !important;
+ table-layout: fixed !important;
+ min-width: 100% !important;
+}
+
+/* Force header and body rows to fill width */
+.trip-management-container .p-datatable-thead,
+.trip-management-container .p-datatable-tbody {
+ width: 100% !important;
+}
+
+.trip-management-container .p-datatable-thead > tr,
+.trip-management-container .p-datatable-tbody > tr {
+ width: 100% !important;
+}
+
+/* Column widths - distribute across table */
+.trip-management-container .p-datatable-thead > tr > th,
+.trip-management-container .p-datatable-tbody > tr > td {
+ /* Default: auto distribute */
+}
+
+/* ID column - narrow */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(1),
+.trip-management-container .p-datatable-tbody > tr > td:nth-child(1) {
+ width: 10% !important;
+}
+
+/* Target column - widest */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(2),
+.trip-management-container .p-datatable-tbody > tr > td:nth-child(2) {
+ width: 22% !important;
+}
+
+/* Tracks column */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(3),
+.trip-management-container .p-datatable-tbody > tr > td:nth-child(3) {
+ width: 10% !important;
+}
+
+/* Status column */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
+.trip-management-container .p-datatable-tbody > tr > td:nth-child(4) {
+ width: 12% !important;
+ text-align: center;
+}
+
+/* Progress column */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
+.trip-management-container .p-datatable-tbody > tr > td:nth-child(5) {
+ width: 16% !important;
+ text-align: center;
+}
+
+/* Quality column */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(6),
+.trip-management-container .p-datatable-tbody > tr > td:nth-child(6) {
+ width: 10% !important;
+ text-align: center;
+}
+
+/* Tarball column - fills remaining */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(7),
+.trip-management-container .p-datatable-tbody > tr > td:nth-child(7) {
+ width: 20% !important;
+}
+
+.trip-management-container .p-datatable td span.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+/* Row hover cursor - indicate clickable */
+.trip-management-container .p-datatable-tbody > tr {
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+}
+
+/* Center-align headers for centered columns */
+.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
+.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
+.trip-management-container .p-datatable-thead > tr > th:nth-child(6) {
+ text-align: center !important;
+}
+
+/* Skeleton loading styles */
+.table-skeleton {
+ width: 100%;
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+.skeleton-row {
+ display: flex;
+ padding: 1rem 0.75rem;
+ border-bottom: 1px solid rgba(128, 128, 128, 0.2);
+}
+
+.skeleton-row:last-child {
+ border-bottom: none;
+}
+
+.skeleton-cell {
+ padding: 0 0.5rem;
+}
+
+.skeleton-bar {
+ height: 1rem;
+ background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+ border-radius: 0.25rem;
+ width: 80%;
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* Light mode skeleton */
+[data-theme="light"] .skeleton-bar {
+ background: linear-gradient(90deg, #e5e5e5 25%, #f0f0f0 50%, #e5e5e5 75%);
+ background-size: 200% 100%;
+}
+
+/* Empty state styles */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 3rem 1rem;
+ text-align: center;
+}
+
+.empty-state-icon {
+ font-size: 3rem;
+ color: #6b7280;
+ margin-bottom: 1rem;
+}
+
+.empty-state-text {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #9ca3af;
+ margin-bottom: 0.25rem;
+}
+
+.empty-state-subtext {
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
/* Dark Mode for Table */
-[data-theme="dark"] .p-datatable {
- background-color: #121212 !important;
+[data-theme="dark"] .trip-management-container .p-datatable {
color: #e5e7eb !important;
}
-[data-theme="dark"] .p-datatable-thead > tr > th {
+[data-theme="dark"] .trip-management-container .p-datatable-thead > tr > th {
background-color: #1f1f1f !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151;
}
-[data-theme="dark"] .p-datatable-tbody > tr {
+[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr {
background-color: #1a1a1a !important;
border-bottom: 1px solid #374151;
color: #e5e7eb !important;
}
-[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
+[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:nth-child(odd) {
background-color: #222 !important;
}
-[data-theme="dark"] .p-datatable-tbody > tr:hover {
+[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:hover {
background-color: #333 !important;
color: #fff !important;
}
/* Paginator Dark Mode */
-[data-theme="dark"] .p-paginator {
+[data-theme="dark"] .trip-management-container .p-paginator {
background-color: #121212 !important;
color: #e5e7eb !important;
border-top: 1px solid #374151 !important;
}
-[data-theme="dark"] .p-paginator .p-paginator-page,
-[data-theme="dark"] .p-paginator .p-paginator-next,
-[data-theme="dark"] .p-paginator .p-paginator-prev,
-[data-theme="dark"] .p-paginator .p-paginator-first,
-[data-theme="dark"] .p-paginator .p-paginator-last {
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page,
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next,
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev,
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-first,
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-last {
color: #e5e7eb !important;
background: transparent !important;
border: none !important;
}
-[data-theme="dark"] .p-paginator .p-paginator-page:hover,
-[data-theme="dark"] .p-paginator .p-paginator-next:hover,
-[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page:hover,
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next:hover,
+[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev:hover {
background-color: #374151 !important;
color: #fff !important;
border-radius: 0.25rem;
}
-[data-theme="dark"] .p-paginator .p-highlight {
+[data-theme="dark"] .trip-management-container .p-paginator .p-highlight {
background-color: #6b7280 !important;
color: #fff !important;
border-radius: 0.25rem !important;
}
-/* Dark Mode for PrimeReact Dialog */
-[data-theme="dark"] .p-dialog {
+/* Dark Mode for PrimeReact Dialog - rendered via portal so needs global selector */
+[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 {
background-color: #1a1a1a !important;
color: #e5e7eb !important;
border-color: #374151 !important;
}
-[data-theme="dark"] .p-dialog .p-dialog-header {
- background-color: #121212 !important;
+[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header {
+ background-color: #171717 !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151 !important;
}
-[data-theme="dark"] .p-dialog .p-dialog-content {
+[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon {
+ color: #e5e7eb !important;
+}
+[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon:hover {
+ background-color: #374151 !important;
+ color: #fff !important;
+}
+[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-content {
background-color: #1a1a1a !important;
color: #e5e7eb !important;
}
-[data-theme="dark"] .p-dialog .p-dialog-footer {
- background-color: #121212 !important;
+[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-footer {
+ background-color: #171717 !important;
border-top: 1px solid #374151 !important;
}
+
+/* Progress Bar Styles */
+.progress-bar-container {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+.progress-bar-track {
+ flex: 1;
+ height: 6px;
+ background-color: rgba(128, 128, 128, 0.2);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.progress-bar-track-lg {
+ height: 10px;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ border-radius: 999px;
+ transition: width 0.3s ease;
+}
+
+.progress-bar-text {
+ font-size: 0.75rem;
+ font-weight: 600;
+ min-width: 2.5rem;
+ text-align: right;
+}
+
+/* Container Styles */
+.trip-management-container {
+ width: 100%;
+}
+
+.trip-management-container .overflow-x-auto {
+ overflow-x: auto;
+ max-width: 100%;
+}
+
+.trip-request-form {
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ .trip-management-container {
+ padding: 1rem;
+ margin: 1rem 0;
+ }
+
+ .trip-management-container h2 {
+ font-size: 1.5rem;
+ }
+
+ /* Stack filters on mobile */
+ .trip-management-container .flex-wrap {
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ /* Make table horizontally scrollable */
+ .trip-management-container .overflow-x-auto {
+ margin: 0 -1rem;
+ padding: 0 1rem;
+ }
+
+ /* Reduce column widths on mobile */
+ .p-datatable-thead > tr > th,
+ .p-datatable-tbody > tr > td {
+ padding: 0.5rem 0.25rem !important;
+ font-size: 0.8rem !important;
+ }
+
+ /* Hide less important columns on small screens */
+ .p-datatable .hide-mobile {
+ display: none !important;
+ }
+}
+
+@media (max-width: 480px) {
+ .trip-management-container {
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ }
+
+ .progress-bar-container {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25rem;
+ }
+
+ .progress-bar-track {
+ width: 100%;
+ }
+
+ .progress-bar-text {
+ text-align: left;
+ }
+}
+
+/* ===== MediaRequestForm Mobile Styles ===== */
+
+/* Form container responsive */
+.trip-request-form {
+ width: 100%;
+ max-width: 48rem;
+ margin: 2.5rem auto;
+ padding: 1.5rem;
+}
+
+@media (max-width: 768px) {
+ .trip-request-form {
+ margin: 1rem auto;
+ padding: 1rem;
+ border-radius: 0.75rem;
+ }
+
+ .trip-request-form h2 {
+ font-size: 1.5rem;
+ }
+
+ /* Quality buttons stack on mobile */
+ .trip-quality-buttons {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .trip-quality-buttons button {
+ width: 100%;
+ }
+
+ /* Accordion improvements for mobile */
+ .p-accordion-header .p-accordion-header-link {
+ padding: 0.75rem !important;
+ font-size: 0.9rem !important;
+ }
+
+ .p-accordion-content {
+ padding: 0.5rem !important;
+ }
+
+ /* Track list items more compact on mobile */
+ .p-accordion-content li {
+ padding: 0.5rem !important;
+ font-size: 0.85rem !important;
+ }
+
+ /* Album header info stacks */
+ .album-header-info {
+ flex-direction: column;
+ align-items: flex-start !important;
+ gap: 0.25rem;
+ }
+
+ /* Audio player controls smaller on mobile */
+ .track-audio-controls button {
+ padding: 0.25rem 0.5rem !important;
+ }
+}
+
+@media (max-width: 480px) {
+ .trip-request-form {
+ margin: 0.5rem;
+ padding: 0.75rem;
+ }
+
+ .trip-request-form h2 {
+ font-size: 1.25rem;
+ }
+
+ /* Input fields full width */
+ .p-autocomplete,
+ .p-autocomplete-input {
+ width: 100% !important;
+ }
+
+ /* Smaller text in track listings */
+ .p-accordion-content li span {
+ font-size: 0.75rem !important;
+ }
+
+ /* Submit button full width */
+ .trip-submit-button {
+ width: 100%;
+ }
+}
diff --git a/src/components/TRip/RequestManagement.jsx b/src/components/TRip/RequestManagement.jsx
index bb6e646..e3ae25f 100644
--- a/src/components/TRip/RequestManagement.jsx
+++ b/src/components/TRip/RequestManagement.jsx
@@ -9,6 +9,7 @@ import { authFetch } from "@/utils/authFetch";
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL } from "@/config";
+import "./RequestManagement.css";
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
@@ -20,6 +21,7 @@ export default function RequestManagement() {
const [filteredRequests, setFilteredRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [isDialogVisible, setIsDialogVisible] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
const pollingRef = useRef(null);
const pollingDetailRef = useRef(null);
@@ -30,8 +32,9 @@ export default function RequestManagement() {
return `${TAR_BASE_URL}/${quality}/${filename}`;
};
- const fetchJobs = async () => {
+ const fetchJobs = async (showLoading = true) => {
try {
+ if (showLoading) setIsLoading(true);
const res = await authFetch(`${API_URL}/trip/jobs/list`);
if (!res.ok) throw new Error("Failed to fetch jobs");
const data = await res.json();
@@ -43,6 +46,8 @@ export default function RequestManagement() {
toastId: 'fetch-fail-toast',
});
}
+ } finally {
+ setIsLoading(false);
}
};
@@ -123,13 +128,13 @@ export default function RequestManagement() {
const statusBodyTemplate = (rowData) => (
-
+
{rowData.status}
);
const qualityBodyTemplate = (rowData) => (
-
+
{rowData.quality}
);
@@ -158,6 +163,34 @@ export default function RequestManagement() {
return `${pct}%`;
};
+ const progressBarTemplate = (rowData) => {
+ const p = rowData.progress;
+ if (p === null || p === undefined || p === "") return "—";
+ const num = Number(p);
+ if (Number.isNaN(num)) return "—";
+ const pct = Math.min(100, Math.max(0, num > 1 ? Math.round(num) : num * 100));
+
+ const getProgressColor = () => {
+ if (rowData.status === "Failed") return "bg-red-500";
+ if (rowData.status === "Finished") return "bg-green-500";
+ if (pct < 30) return "bg-blue-400";
+ if (pct < 70) return "bg-blue-500";
+ return "bg-blue-600";
+ };
+
+ return (
+
+ );
+ };
+
const confirmDelete = (requestId) => {
confirmDialog({
message: "Are you sure you want to delete this request?",
@@ -195,100 +228,15 @@ export default function RequestManagement() {
return (
-
-
+ border border-neutral-200 dark:border-neutral-700">
-
Media Request Management
+
Manage Requests
-
+
({ label: s, value: s }))]}
@@ -298,68 +246,91 @@ export default function RequestManagement() {
/>
-
-
-
- (
-
- {row.id.split("-").slice(-1)[0]}
-
- )}
- />
- textWithEllipsis(row.target, "10rem")} />
- row.tracks} />
-
- formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
-
-
- {/* download icon in header */}
- Tarball
-
+ {isLoading ? (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ ) : (
+
+
+
+ No requests found
+ Requests you submit will appear here
+
}
- body={(row) => {
- const url = tarballUrl(row.tarball, row.quality || "FLAC");
- const encodedURL = encodeURI(url);
- if (!url) return "—";
+ onRowClick={handleRowClick}
+ resizableColumns={false}
+ className="w-full"
+ style={{ width: '100%' }}
+ >
- const fileName = url.split("/").pop();
+ (
+
+ {row.id.split("-").slice(-1)[0]}
+
+ )}
+ />
+ textWithEllipsis(row.target, "100%")} />
+ row.tracks} />
+
+
+
+
+
+ Tarball
+
+ }
+ body={(row) => {
+ const url = tarballUrl(row.tarball, row.quality || "FLAC");
+ const encodedURL = encodeURI(url);
+ if (!url) return "—";
- return (
-
- {truncate(fileName, 16)}
-
- );
- }}
- style={{ width: "10rem" }}
- />
-
-
+ const fileName = url.split("/").pop();
+
+ return (
+
+ {truncate(fileName, 28)}
+
+ );
+ }}
+ />
+
+
+ )}
@@ -402,7 +373,18 @@ export default function RequestManagement() {
)}
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
-
Progress: {formatProgress(selectedRequest.progress)}
+
+
Progress:
+
+
+
1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}%` }}
+ />
+
+
{formatProgress(selectedRequest.progress)}
+
+
)}
diff --git a/src/components/ToastProvider.jsx b/src/components/ToastProvider.jsx
index 8e44745..a167e73 100644
--- a/src/components/ToastProvider.jsx
+++ b/src/components/ToastProvider.jsx
@@ -1,19 +1,23 @@
-import React, { lazy, Suspense } from 'react';
+import React from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
-const CustomToastContainer = () => {
+const CustomToastContainer = ({ theme = 'light', newestOnTop = false, closeOnClick = true }) => {
+ // Map data-theme values to react-toastify theme
+ const toastTheme = theme === 'dark' ? 'dark' : 'light';
+
return (
);
};
diff --git a/src/components/req/ReqForm.jsx b/src/components/req/ReqForm.jsx
index 422a641..56400ee 100644
--- a/src/components/req/ReqForm.jsx
+++ b/src/components/req/ReqForm.jsx
@@ -15,6 +15,7 @@ export default function ReqForm() {
const [selectedTitle, setSelectedTitle] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [suggestions, setSuggestions] = useState([]);
+ const [posterLoading, setPosterLoading] = useState(true);
useEffect(() => {
if (title !== selectedTitle) {
@@ -22,6 +23,7 @@ export default function ReqForm() {
if (selectedItem) setSelectedItem(null);
if (type) setType("");
if (selectedTitle) setSelectedTitle("");
+ setPosterLoading(true);
}
}, [title, selectedTitle, selectedOverview, selectedItem, type]);
@@ -116,18 +118,18 @@ export default function ReqForm() {
return (
-
+
-
+
Request Movies/TV
-
+
Submit your request for review