feat(api): implement rate limiting and SSRF protection across endpoints
- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse. - Introduced SSRF protection in `image-proxy` to block requests to private IP ranges. - Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction. - Refactored authentication checks in various pages to utilize middleware for cleaner code. - Improved JWT key loading with error handling and security warnings for production. - Updated `authFetch` utility to handle token refresh more efficiently with deduplication. - Enhanced rate limiting utility to trust proxy headers from known sources. - Numerous layout / design changes
This commit is contained in:
@@ -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 Archive Styles */
|
||||||
|
|
||||||
.discord-logs-container {
|
.discord-logs-container {
|
||||||
@@ -10,6 +206,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content area (messages) */
|
/* Content area (messages) */
|
||||||
@@ -66,15 +263,17 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem; /* slightly larger */
|
||||||
color: #5c5f66;
|
color: var(--text-color, #2e3338);
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-weight: 700; /* stronger, similar to Discord header */
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .discord-channel-name {
|
[data-theme="dark"] .discord-channel-name {
|
||||||
color: #b5bac1;
|
color: #f2f3f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discord-channel-name svg {
|
.discord-channel-name svg {
|
||||||
@@ -474,6 +673,43 @@
|
|||||||
color: #949ba4;
|
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 {
|
.discord-text {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
line-height: 1.375;
|
line-height: 1.375;
|
||||||
@@ -659,6 +895,208 @@
|
|||||||
color: #b9bbbe;
|
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 */
|
/* Attachments */
|
||||||
.discord-attachments {
|
.discord-attachments {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -971,6 +1409,14 @@ a.discord-embed-title:hover {
|
|||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discord-embed-video-iframe {
|
||||||
|
width: 400px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 225px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reactions */
|
/* Reactions */
|
||||||
.discord-reactions {
|
.discord-reactions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1271,16 +1717,29 @@ a.discord-embed-title:hover {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
font-family: var(--font-mono);
|
/* prefer modern developer-friendly monospace fonts, avoid ligatures for ASCII art */
|
||||||
font-size: 0.875rem;
|
font-family: ui-monospace, "JetBrains Mono", "Fira Code", "Roboto Mono", "Consolas", "Monaco", "Courier New", monospace;
|
||||||
line-height: 1.4;
|
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 {
|
.discord-code-block code {
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: #b5bac1;
|
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 {
|
[data-theme="light"] .discord-code-block {
|
||||||
@@ -1423,14 +1882,22 @@ a.discord-embed-title:hover {
|
|||||||
|
|
||||||
.discord-sidebar {
|
.discord-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 1rem;
|
gap: 0;
|
||||||
width: 220px;
|
width: 260px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: rgba(0, 0, 0, 0.02);
|
background: rgba(0, 0, 0, 0.02);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem;
|
overflow: hidden;
|
||||||
max-height: 600px;
|
align-self: stretch;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-sidebar-channels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
@@ -1439,45 +1906,48 @@ a.discord-embed-title:hover {
|
|||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Guild List (server icons) */
|
/* Guild List (server icons) - compact vertical strip */
|
||||||
.discord-guild-list {
|
.discord-guild-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
gap: 0.35rem;
|
||||||
gap: 0.5rem;
|
padding: 0.35rem;
|
||||||
padding-bottom: 0.75rem;
|
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .discord-guild-list {
|
[data-theme="dark"] .discord-guild-list {
|
||||||
border-right-color: rgba(255, 255, 255, 0.08);
|
border-right-color: rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.discord-guild-btn {
|
.discord-guild-btn {
|
||||||
width: 48px;
|
width: 32px;
|
||||||
height: 48px;
|
height: 32px;
|
||||||
border-radius: 24px;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(0, 0, 0, 0.06);
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.15s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .discord-guild-btn {
|
[data-theme="dark"] .discord-guild-btn {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.discord-guild-btn:hover {
|
.discord-guild-btn:hover {
|
||||||
border-radius: 16px;
|
border-radius: 35%;
|
||||||
background: rgba(88, 101, 242, 0.2);
|
background: rgba(88, 101, 242, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.discord-guild-btn.active {
|
.discord-guild-btn.active {
|
||||||
border-radius: 16px;
|
border-radius: 35%;
|
||||||
background: #5865f2;
|
background: #5865f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1488,7 +1958,7 @@ a.discord-embed-title:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.discord-guild-initial {
|
.discord-guild-initial {
|
||||||
font-size: 1.25rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4f545c;
|
color: #4f545c;
|
||||||
}
|
}
|
||||||
@@ -1534,11 +2004,14 @@ a.discord-embed-title:hover {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.5rem 0.4rem 0.25rem;
|
padding: 0.5rem 0.4rem 0.25rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
letter-spacing: 0.01em;
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: #6d6f78;
|
color: #6d6f78;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .discord-category-header {
|
[data-theme="dark"] .discord-category-header {
|
||||||
@@ -1593,6 +2066,18 @@ a.discord-embed-title:hover {
|
|||||||
color: #8b9dff;
|
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
|
Load More Button
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -1649,22 +2134,26 @@ a.discord-embed-title:hover {
|
|||||||
.discord-sidebar {
|
.discord-sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 250px;
|
max-height: 250px;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.discord-guild-list {
|
.discord-guild-list {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
border-bottom: none;
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
border-right: none;
|
||||||
padding-bottom: 0;
|
padding: 0.35rem;
|
||||||
padding-right: 0.75rem;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .discord-guild-list {
|
[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 {
|
.discord-sidebar .discord-channel-list {
|
||||||
|
|||||||
@@ -101,3 +101,51 @@
|
|||||||
.meme-dialog-nav-next {
|
.meme-dialog-nav-next {
|
||||||
right: 0.5rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,22 +4,23 @@
|
|||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Font families */
|
/* 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;
|
--font-mono: "Geist Mono", ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background-color: #47a3f3;
|
background-color: #3b82f6;
|
||||||
color: #fefefe;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme colors */
|
/* Dark theme colors */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
background-color: #121212;
|
background-color: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
@@ -200,9 +201,24 @@ Custom
|
|||||||
width: 64px;
|
width: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page section - consistent spacing for all page content */
|
||||||
|
.page-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: end;
|
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 {
|
.header-text, .footer-text {
|
||||||
@@ -235,8 +251,103 @@ Custom
|
|||||||
margin-left: 50%;
|
margin-left: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#exclude-checkboxes {
|
/* Search button */
|
||||||
margin-left: 5.5%;
|
.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 {
|
#lyric-search-input {
|
||||||
@@ -247,7 +358,7 @@ Custom
|
|||||||
.lyric-search-input-wrapper {
|
.lyric-search-input-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 900px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyric-search-input-wrapper .p-autocomplete {
|
.lyric-search-input-wrapper .p-autocomplete {
|
||||||
@@ -255,19 +366,46 @@ Custom
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lyric-search-input-wrapper .p-autocomplete-input {
|
.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 {
|
.input-status-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.85rem;
|
right: 1rem;
|
||||||
top: 0;
|
top: 50%;
|
||||||
bottom: 0;
|
transform: translateY(-50%);
|
||||||
transform: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
transition: opacity 0.2s ease, color 0.2s ease;
|
transition: opacity 0.2s ease, color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,10 +420,29 @@ Custom
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-card {
|
.lyrics-card {
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 8px 24px rgba(0,0,0,0.04);
|
||||||
padding: 1.5rem;
|
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 {
|
.lyrics-toolbar {
|
||||||
@@ -294,11 +451,18 @@ Custom
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
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 {
|
.lyrics-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -306,22 +470,27 @@ Custom
|
|||||||
.lyrics-actions {
|
.lyrics-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-size-buttons {
|
.text-size-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
border: 1px solid rgba(79, 70, 229, 0.25);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
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 {
|
.text-size-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
@@ -332,13 +501,17 @@ Custom
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-size-btn.active {
|
.text-size-btn.active {
|
||||||
background: rgba(79, 70, 229, 0.15);
|
background: rgba(0, 0, 0, 0.08);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .text-size-btn.active {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.lyrics-content {
|
.lyrics-content {
|
||||||
line-height: 2.0;
|
line-height: 2.0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'IBM Plex Sans', 'Inter', sans-serif;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@@ -348,6 +521,32 @@ Custom
|
|||||||
line-height: 1.85;
|
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 {
|
.lyrics-action-button {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -384,10 +583,52 @@ Custom
|
|||||||
padding-bottom: 3%;
|
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 {
|
.p-autocomplete-items {
|
||||||
max-height: 200px !important;
|
max-height: 200px !important;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overscroll-behavior: contain;
|
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 {
|
.p-autocomplete-input {
|
||||||
@@ -396,6 +637,7 @@ Custom
|
|||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
transition: border 0.2s;
|
transition: border 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-autocomplete-input:focus {
|
.p-autocomplete-input:focus {
|
||||||
border-color: #4f46e5;
|
border-color: #4f46e5;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -510,18 +752,52 @@ Custom
|
|||||||
/*
|
/*
|
||||||
Toastify customizations
|
Toastify customizations
|
||||||
*/
|
*/
|
||||||
.Toastify__toast--error {
|
.Toastify__toast {
|
||||||
background-color: rgba(255, 0, 0, 0.5) !important;
|
border-radius: 12px !important;
|
||||||
color: inherit !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 {
|
.Toastify__toast--info {
|
||||||
background-color: rgba(217, 242, 255, 0.8) !important;
|
background: rgba(30, 30, 30, 0.95) !important;
|
||||||
color: #000 !important;
|
border-left: 4px solid #3b82f6 !important;
|
||||||
|
color: #93c5fd !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Toastify__toast--success {
|
.Toastify__toast--success {
|
||||||
background-color: rgba(46, 186, 106, 0.8) !important;
|
background: rgba(30, 30, 30, 0.95) !important;
|
||||||
color: inherit !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 {
|
.Toastify__toast--success > .Toastify__toast-icon svg {
|
||||||
@@ -531,3 +807,8 @@ Toastify customizations
|
|||||||
.Toastify__toast--success > .Toastify__toast-icon::after {
|
.Toastify__toast--success > .Toastify__toast-icon::after {
|
||||||
content: "🦄" !important;
|
content: "🦄" !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light mode - keep dark toasts */
|
||||||
|
[data-theme="light"] .Toastify__toast {
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,22 +79,34 @@ nav {
|
|||||||
.nav-user-inline {
|
.nav-user-inline {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.4rem;
|
||||||
padding: 0.3rem 0.85rem;
|
padding: 0.35rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
font-size: 0.82rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1e293b;
|
font-family: 'IBM Plex Sans', sans-serif;
|
||||||
background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(226, 232, 240, 0.85));
|
color: #374151;
|
||||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
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 {
|
[data-theme="dark"] .nav-user-inline {
|
||||||
color: #f1f5f9;
|
color: #e2e8f0;
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
border-color: rgba(71, 85, 105, 0.4);
|
||||||
background: linear-gradient(120deg, rgba(15, 23, 42, 0.85), rgba(30, 41, 59, 0.7));
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.85));
|
||||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.45);
|
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 {
|
.nav-user-inline__icon {
|
||||||
|
|||||||
@@ -749,7 +749,7 @@ export default function Player({ user }) {
|
|||||||
|
|
||||||
<div className="music-time flex justify-between items-center mt-4">
|
<div className="music-time flex justify-between items-center mt-4">
|
||||||
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
|
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
|
||||||
<p className="music-time__last text-sm">{formatTime(trackDuration - elapsedTime)}</p>
|
<p className="music-time__last text-sm">-{formatTime(trackDuration - elapsedTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef, Suspense, lazy } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { API_URL } from '../config.js';
|
import { API_URL } from '../config.js';
|
||||||
import { authFetch } from '../utils/authFetch.js';
|
import { authFetch } from '../utils/authFetch.js';
|
||||||
import Wheel from '@uiw/react-color-wheel';
|
import Wheel from '@uiw/react-color-wheel';
|
||||||
@@ -8,6 +8,8 @@ export default function Lighting() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authFetch(`${API_URL}/lighting/state`)
|
authFetch(`${API_URL}/lighting/state`)
|
||||||
@@ -26,38 +28,44 @@ export default function Lighting() {
|
|||||||
setState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
|
setState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleColorChange = (color) => {
|
// Cleanup debounce on unmount
|
||||||
if (import.meta.env.DEV) console.debug('Handle color change:', color);
|
useEffect(() => {
|
||||||
const { r, g, b } = color.rgb;
|
return () => {
|
||||||
updateLighting({
|
if (debounceRef.current) {
|
||||||
...state,
|
clearTimeout(debounceRef.current);
|
||||||
red: r,
|
}
|
||||||
green: g,
|
};
|
||||||
blue: b,
|
}, []);
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const debounceRef = useRef();
|
// Consolidated debounced update function
|
||||||
|
const updateLighting = useCallback((newState) => {
|
||||||
const updateLighting = (newState) => {
|
|
||||||
setState(newState);
|
setState(newState);
|
||||||
|
setPending(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
// Clear any pending timeout
|
// Clear any pending timeout
|
||||||
if (debounceRef.current) {
|
if (debounceRef.current) {
|
||||||
clearTimeout(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(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
authFetch(`${API_URL}/lighting/state`, {
|
authFetch(`${API_URL}/lighting/state`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newState),
|
body: JSON.stringify(newState),
|
||||||
})
|
})
|
||||||
.then(() => setSuccess(true))
|
.then(() => {
|
||||||
.catch(() => setError('Failed to update lighting state'));
|
setSuccess(true);
|
||||||
}, 100); // 100ms debounce for 25 req/2s rate limit
|
setPending(false);
|
||||||
};
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to update lighting state');
|
||||||
|
setPending(false);
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -129,11 +137,13 @@ export default function Lighting() {
|
|||||||
(v ?? 100) / 100 // value: 0-100 -> 0-1, default to 1 if undefined
|
(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);
|
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', rgb);
|
||||||
|
// Auto power on when changing color
|
||||||
updateLighting({
|
updateLighting({
|
||||||
...state,
|
...state,
|
||||||
red: rgb.red,
|
red: rgb.red,
|
||||||
green: rgb.green,
|
green: rgb.green,
|
||||||
blue: rgb.blue
|
blue: rgb.blue,
|
||||||
|
power: state.power === 'off' ? 'on' : state.power
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
width={180}
|
width={180}
|
||||||
@@ -151,20 +161,18 @@ export default function Lighting() {
|
|||||||
value={state.brightness}
|
value={state.brightness}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const newValue = Number(e.target.value);
|
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,
|
...state,
|
||||||
brightness: newValue,
|
brightness: newValue,
|
||||||
power: newValue === 0 ? 'off' : state.power
|
power: newPower
|
||||||
};
|
});
|
||||||
setState(newState);
|
|
||||||
|
|
||||||
if (debounceRef.current) {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
updateLighting(newState);
|
|
||||||
}, 100); // 100ms debounce for 25 req/2s rate limit
|
|
||||||
}}
|
}}
|
||||||
className="w-full max-w-xs accent-yellow-500"
|
className="w-full max-w-xs accent-yellow-500"
|
||||||
/>
|
/>
|
||||||
@@ -186,7 +194,8 @@ export default function Lighting() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mb-4 text-red-500 text-center font-bold animate-pulse">{error}</div>}
|
{error && <div className="mb-4 text-red-500 text-center font-bold animate-pulse">{error}</div>}
|
||||||
{success && <div className="mb-4 text-green-600 text-center font-bold animate-bounce">Updated!</div>}
|
{success && !pending && <div className="mb-4 text-green-600 text-center font-bold">Updated!</div>}
|
||||||
|
{pending && <div className="mb-4 text-indigo-400 text-center text-sm">Syncing...</div>}
|
||||||
{loading && <div className="mb-4 text-indigo-500 text-center font-bold animate-pulse">Loading...</div>}
|
{loading && <div className="mb-4 text-indigo-500 text-center font-bold animate-pulse">Loading...</div>}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function LoginPage({ loggedIn = false }) {
|
|||||||
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 py-8 text-center">
|
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 py-8 text-center">
|
||||||
<img className="logo-auth mx-auto mb-4" src="/images/zim.png" alt="Logo" />
|
<img className="logo-auth mx-auto mb-4" src="/images/zim.png" alt="Logo" />
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">You're already logged in</h2>
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">You're already logged in</h2>
|
||||||
<p className="text-sm text-gray-800 dark:text-gray-300 mb-4">You do not have permission to access this resource.
|
<p className="text-sm text-gray-800 dark:text-gray-300 mb-4">But you do not have permission to access this resource.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs italic text-gray-800 dark:text-gray-300 mb-4">
|
<p className="text-xs italic text-gray-800 dark:text-gray-300 mb-4">
|
||||||
If you feel you have received this message in error, scream at codey.
|
If you feel you have received this message in error, scream at codey.
|
||||||
|
|||||||
@@ -8,37 +8,41 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
import Box from '@mui/joy/Box';
|
import Box from '@mui/joy/Box';
|
||||||
import Button from "@mui/joy/Button";
|
import Button from "@mui/joy/Button";
|
||||||
import IconButton from "@mui/joy/IconButton";
|
import IconButton from "@mui/joy/IconButton";
|
||||||
import Checkbox from "@mui/joy/Checkbox";
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
|
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
||||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
|
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
|
||||||
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
|
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
|
||||||
import { API_URL } from '../config';
|
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() {
|
export default function LyricSearch() {
|
||||||
const [showLyrics, setShowLyrics] = useState(false);
|
const [showLyrics, setShowLyrics] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lyric-search">
|
<div className="lyric-search">
|
||||||
<h2 className="title">
|
<h1 className="text-3xl font-bold mb-8 text-neutral-900 dark:text-white tracking-tight">
|
||||||
<span>Lyric Search</span>
|
Lyric Search
|
||||||
</h2>
|
</h1>
|
||||||
<div className="card-text my-4">
|
<LyricSearchInputField
|
||||||
<label htmlFor="lyric-search-input">Search:</label>
|
id="lyric-search-input"
|
||||||
<LyricSearchInputField
|
placeholder="Artist - Song"
|
||||||
id="lyric-search-input"
|
setShowLyrics={setShowLyrics}
|
||||||
placeholder="Artist - Song"
|
/>
|
||||||
setShowLyrics={setShowLyrics}
|
|
||||||
/>
|
|
||||||
<div id="spinner" className="hidden">
|
|
||||||
<CircularProgress variant="plain" color="primary" size="md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,6 +57,10 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
const [lyricsResult, setLyricsResult] = useState(null);
|
const [lyricsResult, setLyricsResult] = useState(null);
|
||||||
const [textSize, setTextSize] = useState("normal");
|
const [textSize, setTextSize] = useState("normal");
|
||||||
const [inputStatus, setInputStatus] = useState("hint");
|
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 searchToastRef = useRef(null);
|
||||||
const autoCompleteRef = useRef(null);
|
const autoCompleteRef = useRef(null);
|
||||||
const autoCompleteInputRef = useRef(null);
|
const autoCompleteInputRef = useRef(null);
|
||||||
@@ -122,6 +130,39 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
setSuggestions(json);
|
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
|
// Toggle exclusion state for checkboxes
|
||||||
const toggleExclusion = (source) => {
|
const toggleExclusion = (source) => {
|
||||||
const lower = source.toLowerCase();
|
const lower = source.toLowerCase();
|
||||||
@@ -194,10 +235,34 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
evaluateSearchValue(value);
|
evaluateSearchValue(value);
|
||||||
}, [value, evaluateSearchValue]);
|
}, [value, evaluateSearchValue]);
|
||||||
|
|
||||||
const handleSearch = async (searchValue = value) => {
|
// Robustly hide autocomplete panel
|
||||||
if (autoCompleteRef.current) {
|
const hideAutocompletePanel = useCallback(() => {
|
||||||
autoCompleteRef.current.hide();
|
// 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
|
autoCompleteInputRef.current?.blur(); // blur early so the suggestion panel closes immediately
|
||||||
|
|
||||||
const evaluation = evaluateSearchValue(searchValue);
|
const evaluation = evaluateSearchValue(searchValue);
|
||||||
@@ -210,6 +275,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
const { artist, song } = evaluation;
|
const { artist, song } = evaluation;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLyricsResult(null);
|
setLyricsResult(null);
|
||||||
|
setYoutubeVideo(null); // Reset YouTube video
|
||||||
setShowLyrics(false);
|
setShowLyrics(false);
|
||||||
|
|
||||||
const toastId = "lyrics-searching-toast";
|
const toastId = "lyrics-searching-toast";
|
||||||
@@ -251,12 +317,21 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
setTextSize("normal");
|
setTextSize("normal");
|
||||||
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
|
||||||
|
setHighlightedVerse(null);
|
||||||
|
setIsLyricsVisible(false);
|
||||||
|
// Trigger fade-in animation
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setIsLyricsVisible(true));
|
||||||
|
});
|
||||||
setShowLyrics(true);
|
setShowLyrics(true);
|
||||||
|
|
||||||
// Update URL hash with search parameters
|
// Update URL hash with search parameters
|
||||||
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
|
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
|
||||||
window.history.pushState(null, '', hash);
|
window.history.pushState(null, '', hash);
|
||||||
|
|
||||||
|
// Search for YouTube video (don't block on this)
|
||||||
|
fetchYouTubeVideo(data.artist, data.song);
|
||||||
|
|
||||||
dismissSearchToast();
|
dismissSearchToast();
|
||||||
toast.success(`Found! (Took ${duration}s)`, {
|
toast.success(`Found! (Took ${duration}s)`, {
|
||||||
autoClose: 2500,
|
autoClose: 2500,
|
||||||
@@ -270,6 +345,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
hideAutocompletePanel();
|
||||||
autoCompleteInputRef.current?.blur();
|
autoCompleteInputRef.current?.blur();
|
||||||
searchButtonRef.current?.blur();
|
searchButtonRef.current?.blur();
|
||||||
searchToastRef.current = null;
|
searchToastRef.current = null;
|
||||||
@@ -279,6 +355,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
hideAutocompletePanel();
|
||||||
handleSearch();
|
handleSearch();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -348,8 +425,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
onShow={handlePanelShow}
|
onShow={handlePanelShow}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{ width: '100%', maxWidth: '900px' }}
|
style={{ width: '100%' }}
|
||||||
inputStyle={{ width: '100%' }}
|
|
||||||
className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`}
|
className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`}
|
||||||
aria-invalid={inputStatus === "error"}
|
aria-invalid={inputStatus === "error"}
|
||||||
aria-label={`Lyric search input. ${statusTitle}`}
|
aria-label={`Lyric search input. ${statusTitle}`}
|
||||||
@@ -370,30 +446,31 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
{statusTitle}
|
{statusTitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-4 mt-5">
|
||||||
onClick={() => handleSearch()}
|
<Button
|
||||||
className="btn"
|
onClick={() => handleSearch()}
|
||||||
ref={searchButtonRef}
|
className="search-btn"
|
||||||
>
|
ref={searchButtonRef}
|
||||||
Search
|
>
|
||||||
</Button>
|
Search
|
||||||
<div className="mt-4">
|
</Button>
|
||||||
Exclude:<br />
|
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700" aria-hidden="true"></div>
|
||||||
<div id="exclude-checkboxes">
|
<div className="exclude-sources">
|
||||||
|
<span className="exclude-label">Exclude:</span>
|
||||||
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
|
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
|
||||||
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib-Cache" onToggle={toggleExclusion} />
|
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib" onToggle={toggleExclusion} />
|
||||||
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
|
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mt-3">
|
<div className="mt-6 flex justify-center">
|
||||||
<CircularProgress variant="plain" color="primary" size="md" />
|
<CircularProgress variant="plain" color="primary" size="md" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lyricsResult && (
|
{lyricsResult && (
|
||||||
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
<div className={`lyrics-card lyrics-card-${theme} mt-6 p-5 rounded-xl shadow-lg lyrics-card-animate ${isLyricsVisible ? 'lyrics-card-visible' : ''}`}>
|
||||||
<div className="lyrics-toolbar">
|
<div className="lyrics-toolbar">
|
||||||
<div className="lyrics-title">
|
<div className="lyrics-title">
|
||||||
{lyricsResult.artist} - {lyricsResult.song}
|
{lyricsResult.artist} - {lyricsResult.song}
|
||||||
@@ -439,10 +516,40 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
|||||||
>
|
>
|
||||||
<LinkIcon fontSize="small" />
|
<LinkIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{youtubeLoading && (
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="plain"
|
||||||
|
color="neutral"
|
||||||
|
aria-label="Loading YouTube video..."
|
||||||
|
title="Finding video..."
|
||||||
|
className="lyrics-action-button"
|
||||||
|
disabled
|
||||||
|
sx={{ opacity: 0.3 }}
|
||||||
|
>
|
||||||
|
<PlayCircleOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{!youtubeLoading && youtubeVideo && (
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="plain"
|
||||||
|
color="neutral"
|
||||||
|
aria-label="Watch on YouTube"
|
||||||
|
title={`Watch: ${youtubeVideo.title}`}
|
||||||
|
className="lyrics-action-button"
|
||||||
|
component="a"
|
||||||
|
href={`https://www.youtube.com/watch?v=${youtubeVideo.video_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<PlayCircleOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
|
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
|
||||||
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
|
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(lyricsResult.lyrics) }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -460,10 +567,11 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const verifyExclusions = () => {
|
const verifyExclusions = () => {
|
||||||
const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]");
|
const checkboxes = document.querySelectorAll(".exclude-chip");
|
||||||
const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
|
const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
|
||||||
|
|
||||||
if (checkedCount === 3) {
|
if (checkedCount === 3) {
|
||||||
|
// Reset all by triggering clicks
|
||||||
checkboxes.forEach(cb => cb.click());
|
checkboxes.forEach(cb => cb.click());
|
||||||
if (!toast.isActive("lyrics-exclusion-reset-toast")) {
|
if (!toast.isActive("lyrics-exclusion-reset-toast")) {
|
||||||
toast.error("All sources were excluded; exclusions have been reset.",
|
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 handleClick = () => {
|
||||||
const newChecked = e.target.checked;
|
const newChecked = !checked;
|
||||||
setChecked(newChecked);
|
setChecked(newChecked);
|
||||||
if (props.onToggle) {
|
if (props.onToggle) {
|
||||||
const source = props.label; // Use label as source identifier
|
const source = props.label;
|
||||||
props.onToggle(source);
|
props.onToggle(source);
|
||||||
}
|
}
|
||||||
verifyExclusions();
|
setTimeout(verifyExclusions, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<button
|
||||||
<Checkbox
|
type="button"
|
||||||
id={props.id}
|
id={props.id}
|
||||||
key={props.label}
|
className={`exclude-chip ${checked ? 'exclude-chip--active' : ''}`}
|
||||||
checked={checked}
|
data-checked={checked}
|
||||||
label={props.label}
|
onClick={handleClick}
|
||||||
style={{ color: "inherit" }}
|
aria-pressed={checked}
|
||||||
onChange={handleChange}
|
>
|
||||||
/>
|
{props.label}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ const Memes = () => {
|
|||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [imageLoading, setImageLoading] = useState({});
|
||||||
const observerRef = useRef();
|
const observerRef = useRef();
|
||||||
|
const touchStartRef = useRef(null);
|
||||||
|
const touchEndRef = useRef(null);
|
||||||
const theme = document.documentElement.getAttribute("data-theme")
|
const theme = document.documentElement.getAttribute("data-theme")
|
||||||
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
|
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
|
||||||
|
|
||||||
@@ -158,6 +161,43 @@ const Memes = () => {
|
|||||||
setSelectedIndex(-1);
|
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 () => {
|
const handleCopyImage = useCallback(async () => {
|
||||||
if (!selectedImage) return;
|
if (!selectedImage) return;
|
||||||
try {
|
try {
|
||||||
@@ -181,6 +221,7 @@ const Memes = () => {
|
|||||||
<div className="grid-container">
|
<div className="grid-container">
|
||||||
{images.map((img, i) => {
|
{images.map((img, i) => {
|
||||||
const isLast = i === images.length - 1;
|
const isLast = i === images.length - 1;
|
||||||
|
const isLoading = imageLoading[img.id] !== false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={img.id}
|
key={img.id}
|
||||||
@@ -191,13 +232,18 @@ const Memes = () => {
|
|||||||
setSelectedIndex(i);
|
setSelectedIndex(i);
|
||||||
prefetchImage(images[i + 1]);
|
prefetchImage(images[i + 1]);
|
||||||
}}
|
}}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer', position: 'relative' }}
|
||||||
>
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="meme-skeleton" />
|
||||||
|
)}
|
||||||
<Image
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
alt={`meme-${img.id}`}
|
alt={`meme-${img.id}`}
|
||||||
imageClassName="meme-img"
|
imageClassName={`meme-img ${isLoading ? 'meme-img-loading' : ''}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
onLoad={() => handleImageLoad(img.id)}
|
||||||
|
onLoadStart={() => handleImageLoadStart(img.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -239,7 +285,12 @@ const Memes = () => {
|
|||||||
dismissableMask={true}
|
dismissableMask={true}
|
||||||
>
|
>
|
||||||
{selectedImage && (
|
{selectedImage && (
|
||||||
<div className="meme-dialog-body">
|
<div
|
||||||
|
className="meme-dialog-body"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="meme-dialog-nav meme-dialog-nav-prev"
|
className="meme-dialog-nav meme-dialog-nav-prev"
|
||||||
|
|||||||
@@ -7,26 +7,27 @@ export default function BreadcrumbNav({ currentPage }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<nav aria-label="breadcrumb" className="mb-8 flex items-center gap-2 text-sm">
|
||||||
<nav aria-label="breadcrumb" className="mb-6 flex gap-4 text-sm font-medium text-blue-600 dark:text-blue-400">
|
{pages.map(({ key, label, href }, i) => {
|
||||||
{pages.map(({ key, label, href }, i) => {
|
const isActive = currentPage === key;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={key}>
|
<React.Fragment key={key}>
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
className={`${currentPage === key
|
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
|
||||||
? "!font-bold underline" // active: always underlined + bold
|
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
|
||||||
: "hover:underline" // inactive: underline only on hover
|
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
}`}
|
}`}
|
||||||
aria-current={currentPage === key ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
{i < pages.length - 1 && <span aria-hidden="true">/</span>}
|
{i < pages.length - 1 && (
|
||||||
</React.Fragment>
|
<span className="text-neutral-400 dark:text-neutral-600" aria-hidden="true">/</span>
|
||||||
);
|
)}
|
||||||
})}
|
</React.Fragment>
|
||||||
</nav >
|
);
|
||||||
</div>
|
})}
|
||||||
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AutoComplete } from "primereact/autocomplete";
|
|||||||
import { authFetch } from "@/utils/authFetch";
|
import { authFetch } from "@/utils/authFetch";
|
||||||
import BreadcrumbNav from "./BreadcrumbNav";
|
import BreadcrumbNav from "./BreadcrumbNav";
|
||||||
import { API_URL, ENVIRONMENT } from "@/config";
|
import { API_URL, ENVIRONMENT } from "@/config";
|
||||||
|
import "./RequestManagement.css";
|
||||||
|
|
||||||
export default function MediaRequestForm() {
|
export default function MediaRequestForm() {
|
||||||
const [type, setType] = useState("artist");
|
const [type, setType] = useState("artist");
|
||||||
@@ -918,7 +919,7 @@ export default function MediaRequestForm() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
<div className="trip-request-form mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
||||||
<style>{`
|
<style>{`
|
||||||
/* Accordion tab backgrounds & text */
|
/* Accordion tab backgrounds & text */
|
||||||
.p-accordion-tab {
|
.p-accordion-tab {
|
||||||
@@ -990,7 +991,8 @@ export default function MediaRequestForm() {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<BreadcrumbNav currentPage="request" />
|
<BreadcrumbNav currentPage="request" />
|
||||||
<h2 className="text-3xl font-semibold mt-0">New Request</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
||||||
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<label htmlFor="artistInput">Artist: </label>
|
<label htmlFor="artistInput">Artist: </label>
|
||||||
|
|||||||
@@ -1,81 +1,442 @@
|
|||||||
/* Table and Dark Overrides */
|
/* Table and Dark Overrides */
|
||||||
.p-datatable {
|
.trip-management-container {
|
||||||
table-layout: fixed !important;
|
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;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 */
|
/* Dark Mode for Table */
|
||||||
[data-theme="dark"] .p-datatable {
|
[data-theme="dark"] .trip-management-container .p-datatable {
|
||||||
background-color: #121212 !important;
|
|
||||||
color: #e5e7eb !important;
|
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;
|
background-color: #1f1f1f !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
border-bottom: 1px solid #374151;
|
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;
|
background-color: #1a1a1a !important;
|
||||||
border-bottom: 1px solid #374151;
|
border-bottom: 1px solid #374151;
|
||||||
color: #e5e7eb !important;
|
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;
|
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;
|
background-color: #333 !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paginator Dark Mode */
|
/* Paginator Dark Mode */
|
||||||
[data-theme="dark"] .p-paginator {
|
[data-theme="dark"] .trip-management-container .p-paginator {
|
||||||
background-color: #121212 !important;
|
background-color: #121212 !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
border-top: 1px solid #374151 !important;
|
border-top: 1px solid #374151 !important;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .p-paginator .p-paginator-page,
|
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page,
|
||||||
[data-theme="dark"] .p-paginator .p-paginator-next,
|
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next,
|
||||||
[data-theme="dark"] .p-paginator .p-paginator-prev,
|
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev,
|
||||||
[data-theme="dark"] .p-paginator .p-paginator-first,
|
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-first,
|
||||||
[data-theme="dark"] .p-paginator .p-paginator-last {
|
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-last {
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
|
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page:hover,
|
||||||
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
|
[data-theme="dark"] .trip-management-container .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-prev:hover {
|
||||||
background-color: #374151 !important;
|
background-color: #374151 !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
border-radius: 0.25rem;
|
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;
|
background-color: #6b7280 !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
border-radius: 0.25rem !important;
|
border-radius: 0.25rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark Mode for PrimeReact Dialog */
|
/* Dark Mode for PrimeReact Dialog - rendered via portal so needs global selector */
|
||||||
[data-theme="dark"] .p-dialog {
|
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 {
|
||||||
background-color: #1a1a1a !important;
|
background-color: #1a1a1a !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
border-color: #374151 !important;
|
border-color: #374151 !important;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .p-dialog .p-dialog-header {
|
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header {
|
||||||
background-color: #121212 !important;
|
background-color: #171717 !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
border-bottom: 1px solid #374151 !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;
|
background-color: #1a1a1a !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] .p-dialog .p-dialog-footer {
|
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-footer {
|
||||||
background-color: #121212 !important;
|
background-color: #171717 !important;
|
||||||
border-top: 1px solid #374151 !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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { authFetch } from "@/utils/authFetch";
|
|||||||
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
|
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
|
||||||
import BreadcrumbNav from "./BreadcrumbNav";
|
import BreadcrumbNav from "./BreadcrumbNav";
|
||||||
import { API_URL } from "@/config";
|
import { API_URL } from "@/config";
|
||||||
|
import "./RequestManagement.css";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
||||||
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
|
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
|
||||||
@@ -20,6 +21,7 @@ export default function RequestManagement() {
|
|||||||
const [filteredRequests, setFilteredRequests] = useState([]);
|
const [filteredRequests, setFilteredRequests] = useState([]);
|
||||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||||
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const pollingRef = useRef(null);
|
const pollingRef = useRef(null);
|
||||||
const pollingDetailRef = useRef(null);
|
const pollingDetailRef = useRef(null);
|
||||||
|
|
||||||
@@ -30,8 +32,9 @@ export default function RequestManagement() {
|
|||||||
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async (showLoading = true) => {
|
||||||
try {
|
try {
|
||||||
|
if (showLoading) setIsLoading(true);
|
||||||
const res = await authFetch(`${API_URL}/trip/jobs/list`);
|
const res = await authFetch(`${API_URL}/trip/jobs/list`);
|
||||||
if (!res.ok) throw new Error("Failed to fetch jobs");
|
if (!res.ok) throw new Error("Failed to fetch jobs");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -43,6 +46,8 @@ export default function RequestManagement() {
|
|||||||
toastId: 'fetch-fail-toast',
|
toastId: 'fetch-fail-toast',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,13 +128,13 @@ export default function RequestManagement() {
|
|||||||
|
|
||||||
|
|
||||||
const statusBodyTemplate = (rowData) => (
|
const statusBodyTemplate = (rowData) => (
|
||||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
|
<span className={`inline-flex items-center justify-center min-w-[90px] px-3 py-1 rounded-full font-semibold text-xs ${getStatusColorClass(rowData.status)}`}>
|
||||||
{rowData.status}
|
{rowData.status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const qualityBodyTemplate = (rowData) => (
|
const qualityBodyTemplate = (rowData) => (
|
||||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getQualityColorClass(rowData.quality)}`}>
|
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
|
||||||
{rowData.quality}
|
{rowData.quality}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -158,6 +163,34 @@ export default function RequestManagement() {
|
|||||||
return `${pct}%`;
|
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 (
|
||||||
|
<div className="progress-bar-container">
|
||||||
|
<div className="progress-bar-track">
|
||||||
|
<div
|
||||||
|
className={`progress-bar-fill ${getProgressColor()}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="progress-bar-text">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = (requestId) => {
|
const confirmDelete = (requestId) => {
|
||||||
confirmDialog({
|
confirmDialog({
|
||||||
message: "Are you sure you want to delete this request?",
|
message: "Are you sure you want to delete this request?",
|
||||||
@@ -195,100 +228,15 @@ export default function RequestManagement() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="w-max my-10 p-6 rounded-xl shadow-md
|
<div className="trip-management-container my-10 p-4 sm:p-6 rounded-xl shadow-md
|
||||||
bg-white dark:bg-neutral-900
|
bg-white dark:bg-neutral-900
|
||||||
text-neutral-900 dark:text-neutral-100
|
text-neutral-900 dark:text-neutral-100
|
||||||
border border-neutral-200 dark:border-neutral-700
|
border border-neutral-200 dark:border-neutral-700">
|
||||||
sm:p-4 md:p-6">
|
|
||||||
<style>{`
|
|
||||||
/* Table and Dark Overrides */
|
|
||||||
.p-datatable {
|
|
||||||
table-layout: fixed !important;
|
|
||||||
}
|
|
||||||
.p-datatable td span.truncate {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .p-datatable {
|
|
||||||
background-color: #121212 !important;
|
|
||||||
color: #e5e7eb !important;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .p-datatable-thead > tr > th {
|
|
||||||
background-color: #1f1f1f !important;
|
|
||||||
color: #e5e7eb !important;
|
|
||||||
border-bottom: 1px solid #374151;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .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) {
|
|
||||||
background-color: #222 !important;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .p-datatable-tbody > tr:hover {
|
|
||||||
background-color: #333 !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
/* Paginator Dark Mode */
|
|
||||||
[data-theme="dark"] .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 {
|
|
||||||
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 {
|
|
||||||
background-color: #374151 !important;
|
|
||||||
color: #fff !important;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .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 {
|
|
||||||
background-color: #1a1a1a !important;
|
|
||||||
color: #e5e7eb !important;
|
|
||||||
border-color: #374151 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .p-dialog .p-dialog-header {
|
|
||||||
background-color: #121212 !important;
|
|
||||||
color: #e5e7eb !important;
|
|
||||||
border-bottom: 1px solid #374151 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .p-dialog .p-dialog-content {
|
|
||||||
background-color: #1a1a1a !important;
|
|
||||||
color: #e5e7eb !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .p-dialog .p-dialog-footer {
|
|
||||||
background-color: #121212 !important;
|
|
||||||
border-top: 1px solid #374151 !important;
|
|
||||||
color: #e5e7eb !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<BreadcrumbNav currentPage="management" />
|
<BreadcrumbNav currentPage="management" />
|
||||||
<h2 className="text-3xl font-semibold mt-0">Media Request Management</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-6">Manage Requests</h2>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-6 mb-6">
|
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
options={[{ label: "All Statuses", value: "all" }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
|
options={[{ label: "All Statuses", value: "all" }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
|
||||||
@@ -298,68 +246,91 @@ export default function RequestManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-max overflow-x-auto rounded-lg">
|
{isLoading ? (
|
||||||
<DataTable
|
<div className="table-skeleton">
|
||||||
value={filteredRequests}
|
{[...Array(5)].map((_, i) => (
|
||||||
paginator
|
<div key={i} className="skeleton-row">
|
||||||
rows={10}
|
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||||
removableSort
|
<div className="skeleton-cell w-[22%]"><div className="skeleton-bar" /></div>
|
||||||
sortMode="multiple"
|
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||||
emptyMessage="No requests found."
|
<div className="skeleton-cell w-[12%]"><div className="skeleton-bar" /></div>
|
||||||
onRowClick={handleRowClick}
|
<div className="skeleton-cell w-[16%]"><div className="skeleton-bar" /></div>
|
||||||
>
|
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
|
||||||
|
<div className="skeleton-cell w-[20%]"><div className="skeleton-bar" /></div>
|
||||||
<Column
|
</div>
|
||||||
field="id"
|
))}
|
||||||
header="ID"
|
</div>
|
||||||
style={{ width: "6rem" }}
|
) : (
|
||||||
body={(row) => (
|
<div className="table-wrapper w-full">
|
||||||
<span title={row.id}>
|
<DataTable
|
||||||
{row.id.split("-").slice(-1)[0]}
|
value={filteredRequests}
|
||||||
</span>
|
paginator
|
||||||
)}
|
rows={10}
|
||||||
/>
|
removableSort
|
||||||
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
|
sortMode="multiple"
|
||||||
<Column field="tracks" header="# Tracks" style={{ width: "8rem" }} body={(row) => row.tracks} />
|
emptyMessage={
|
||||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
|
<div className="empty-state">
|
||||||
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
|
<i className="pi pi-inbox empty-state-icon" />
|
||||||
<Column
|
<p className="empty-state-text">No requests found</p>
|
||||||
field="quality"
|
<p className="empty-state-subtext">Requests you submit will appear here</p>
|
||||||
header="Quality"
|
</div>
|
||||||
body={qualityBodyTemplate}
|
|
||||||
style={{ width: "6rem", textAlign: "center" }}
|
|
||||||
sortable />
|
|
||||||
<Column
|
|
||||||
field="tarball"
|
|
||||||
header={
|
|
||||||
<span className="flex items-center">
|
|
||||||
<i className="pi pi-download mr-1" /> {/* download icon in header */}
|
|
||||||
Tarball
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
body={(row) => {
|
onRowClick={handleRowClick}
|
||||||
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
resizableColumns={false}
|
||||||
const encodedURL = encodeURI(url);
|
className="w-full"
|
||||||
if (!url) return "—";
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
|
||||||
const fileName = url.split("/").pop();
|
<Column
|
||||||
|
field="id"
|
||||||
|
header="ID"
|
||||||
|
body={(row) => (
|
||||||
|
<span title={row.id}>
|
||||||
|
{row.id.split("-").slice(-1)[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Column field="target" header="Target" sortable body={(row) => textWithEllipsis(row.target, "100%")} />
|
||||||
|
<Column field="tracks" header="# Tracks" body={(row) => row.tracks} />
|
||||||
|
<Column field="status" header="Status" body={statusBodyTemplate} style={{ textAlign: "center" }} sortable />
|
||||||
|
<Column field="progress" header="Progress" body={progressBarTemplate} style={{ textAlign: "center" }} sortable />
|
||||||
|
<Column
|
||||||
|
field="quality"
|
||||||
|
header="Quality"
|
||||||
|
body={qualityBodyTemplate}
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
sortable />
|
||||||
|
<Column
|
||||||
|
field="tarball"
|
||||||
|
header={
|
||||||
|
<span className="flex items-center">
|
||||||
|
<i className="pi pi-download mr-1" />
|
||||||
|
Tarball
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
body={(row) => {
|
||||||
|
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||||
|
const encodedURL = encodeURI(url);
|
||||||
|
if (!url) return "—";
|
||||||
|
|
||||||
return (
|
const fileName = url.split("/").pop();
|
||||||
<a
|
|
||||||
href={encodedURL}
|
return (
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href={encodedURL}
|
||||||
className="truncate text-blue-500 hover:underline"
|
target="_blank"
|
||||||
title={fileName}
|
rel="noopener noreferrer"
|
||||||
>
|
className="truncate text-blue-500 hover:underline"
|
||||||
{truncate(fileName, 16)}
|
title={fileName}
|
||||||
</a>
|
>
|
||||||
);
|
{truncate(fileName, 28)}
|
||||||
}}
|
</a>
|
||||||
style={{ width: "10rem" }}
|
);
|
||||||
/>
|
}}
|
||||||
</DataTable>
|
/>
|
||||||
</div>
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
@@ -402,7 +373,18 @@ export default function RequestManagement() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
|
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
|
||||||
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
|
<div className="col-span-2">
|
||||||
|
<strong>Progress:</strong>
|
||||||
|
<div className="progress-bar-container mt-2">
|
||||||
|
<div className="progress-bar-track progress-bar-track-lg">
|
||||||
|
<div
|
||||||
|
className={`progress-bar-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="progress-bar-text">{formatProgress(selectedRequest.progress)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import React, { lazy, Suspense } from 'react';
|
import React from 'react';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
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 (
|
return (
|
||||||
<ToastContainer
|
<ToastContainer
|
||||||
position="top-right"
|
position="top-right"
|
||||||
autoClose={5000}
|
autoClose={5000}
|
||||||
hideProgressBar={false}
|
hideProgressBar={false}
|
||||||
newestOnTop={false}
|
newestOnTop={newestOnTop}
|
||||||
closeOnClick
|
closeOnClick={closeOnClick}
|
||||||
rtl={false}
|
rtl={false}
|
||||||
pauseOnFocusLoss={false}
|
pauseOnFocusLoss={false}
|
||||||
draggable
|
draggable
|
||||||
pauseOnHover
|
pauseOnHover
|
||||||
|
theme={toastTheme}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default function ReqForm() {
|
|||||||
const [selectedTitle, setSelectedTitle] = useState("");
|
const [selectedTitle, setSelectedTitle] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
|
const [posterLoading, setPosterLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title !== selectedTitle) {
|
if (title !== selectedTitle) {
|
||||||
@@ -22,6 +23,7 @@ export default function ReqForm() {
|
|||||||
if (selectedItem) setSelectedItem(null);
|
if (selectedItem) setSelectedItem(null);
|
||||||
if (type) setType("");
|
if (type) setType("");
|
||||||
if (selectedTitle) setSelectedTitle("");
|
if (selectedTitle) setSelectedTitle("");
|
||||||
|
setPosterLoading(true);
|
||||||
}
|
}
|
||||||
}, [title, selectedTitle, selectedOverview, selectedItem, type]);
|
}, [title, selectedTitle, selectedOverview, selectedItem, type]);
|
||||||
|
|
||||||
@@ -116,18 +118,18 @@ export default function ReqForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
||||||
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#1E1E1E] rounded-3xl shadow-2xl border border-gray-200 dark:border-gray-700">
|
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#141414] rounded-2xl shadow-lg shadow-neutral-900/5 dark:shadow-black/20 border border-neutral-200/60 dark:border-neutral-800/60">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-2">
|
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-2 tracking-tight font-['IBM_Plex_Sans',sans-serif]">
|
||||||
Request Movies/TV
|
Request Movies/TV
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
|
||||||
Submit your request for review
|
Submit your request for review
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="title" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<label htmlFor="title" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Title <span className="text-red-500">*</span>
|
Title <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
@@ -148,43 +150,50 @@ export default function ReqForm() {
|
|||||||
placeholder="Enter movie or TV title"
|
placeholder="Enter movie or TV title"
|
||||||
title="Enter movie or TV show title"
|
title="Enter movie or TV show title"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
inputClassName="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors"
|
inputClassName="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||||
panelClassName="border-2 border-gray-200 dark:border-gray-600 rounded-xl"
|
panelClassName="rounded-xl overflow-hidden"
|
||||||
field="label"
|
field="label"
|
||||||
onShow={attachScrollFix}
|
onShow={attachScrollFix}
|
||||||
itemTemplate={(item) => (
|
itemTemplate={(item) => (
|
||||||
<div className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
<div className="p-2 rounded">
|
||||||
<span className="font-medium">{item.label}</span>
|
<span className="font-medium">{item.label}</span>
|
||||||
{item.year && <span className="text-sm text-gray-500 ml-2">({item.year})</span>}
|
{item.year && <span className="text-sm text-neutral-500 ml-2">({item.year})</span>}
|
||||||
<span className="text-xs text-gray-400 ml-2 uppercase">{item.mediaType === 'tv' ? 'TV' : 'Movie'}</span>
|
<span className="text-xs text-neutral-400 ml-2 uppercase">{item.mediaType === 'tv' ? 'TV' : 'Movie'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{selectedItem && selectedTypeLabel && (
|
{selectedItem && selectedTypeLabel && (
|
||||||
<div className="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
|
<div className="text-xs font-medium uppercase text-neutral-500 dark:text-neutral-400 tracking-wide">
|
||||||
Selected type: {selectedTypeLabel}
|
Selected type: <span className="font-bold text-neutral-700 dark:text-neutral-200">{selectedTypeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedOverview && (
|
{selectedOverview && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Synopsis
|
Synopsis
|
||||||
</label>
|
</label>
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600">
|
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||||
{selectedOverview}
|
{selectedOverview}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{selectedItem?.poster_path && (
|
{selectedItem?.poster_path && (
|
||||||
<img
|
<div className="relative w-24 sm:w-32 md:w-40 flex-shrink-0 overflow-hidden rounded-lg">
|
||||||
src={`https://image.tmdb.org/t/p/w200${selectedItem.poster_path}`}
|
{posterLoading && (
|
||||||
alt="Poster"
|
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-lg animate-pulse" style={{ aspectRatio: '2/3' }} />
|
||||||
className="w-24 sm:w-32 md:w-40 h-auto rounded-lg border border-gray-200 dark:border-gray-600"
|
)}
|
||||||
/>
|
<img
|
||||||
|
src={`https://image.tmdb.org/t/p/w200${selectedItem.poster_path}`}
|
||||||
|
alt="Poster"
|
||||||
|
className={`w-full h-auto rounded-lg border border-neutral-200 dark:border-neutral-700 transition-opacity duration-300 ${posterLoading ? 'hidden' : 'block'}`}
|
||||||
|
onLoad={() => setPosterLoading(false)}
|
||||||
|
onError={() => setPosterLoading(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,28 +201,28 @@ export default function ReqForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="year" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<label htmlFor="year" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Year <span className="text-gray-500">(optional)</span>
|
Year <span className="text-neutral-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<InputText
|
<InputText
|
||||||
id="year"
|
id="year"
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => setYear(e.target.value)}
|
onChange={(e) => setYear(e.target.value)}
|
||||||
placeholder="e.g. 2023"
|
placeholder="e.g. 2023"
|
||||||
className="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors"
|
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="requester" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<label htmlFor="requester" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Your Name <span className="text-gray-500">(optional)</span>
|
Your Name <span className="text-neutral-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<InputText
|
<InputText
|
||||||
id="requester"
|
id="requester"
|
||||||
value={requester}
|
value={requester}
|
||||||
onChange={(e) => setRequester(e.target.value)}
|
onChange={(e) => setRequester(e.target.value)}
|
||||||
placeholder="Who is requesting this?"
|
placeholder="Who is requesting this?"
|
||||||
className="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors"
|
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,7 +230,7 @@ export default function ReqForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full py-3 px-6 bg-[#12f8f4] text-gray-900 font-semibold rounded-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 px-6 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Submitting..." : "Submit Request"}
|
{isSubmitting ? "Submitting..." : "Submit Request"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const RADIO_API_URL = "https://radio-api.codey.lol";
|
|||||||
export const socialLinks = {
|
export const socialLinks = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAJOR_VERSION = "0.4"
|
export const MAJOR_VERSION = "0.5"
|
||||||
export const RELEASE_FLAG = null;
|
export const RELEASE_FLAG = null;
|
||||||
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
||||||
|
|
||||||
@@ -52,3 +52,22 @@ export const WHITELABELS = {
|
|||||||
export const SUBSITES = {
|
export const SUBSITES = {
|
||||||
'req.boatson.boats': '/subsites/req',
|
'req.boatson.boats': '/subsites/req',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Protected routes configuration
|
||||||
|
// Routes listed here require authentication - middleware will redirect to /login if not authenticated
|
||||||
|
// Can be a string (just auth required) or object with roles array for role-based access
|
||||||
|
export const PROTECTED_ROUTES = [
|
||||||
|
'/radio',
|
||||||
|
{ path: '/lighting', roles: ['lighting'] },
|
||||||
|
{ path: '/discord-logs', roles: ['discord'] },
|
||||||
|
'/memes',
|
||||||
|
'/TRip',
|
||||||
|
'/TRip/requests',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Routes that should skip auth check entirely (public routes)
|
||||||
|
export const PUBLIC_ROUTES = [
|
||||||
|
'/',
|
||||||
|
'/login',
|
||||||
|
'/api/',
|
||||||
|
];
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
16
src/env.d.ts
vendored
16
src/env.d.ts
vendored
@@ -1 +1,17 @@
|
|||||||
/// <reference path="../.astro/types.d.ts" />
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
|
|
||||||
|
// Extend Astro.locals with custom properties set by middleware
|
||||||
|
declare namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
user?: string;
|
||||||
|
roles?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
whitelabel?: string | null;
|
||||||
|
isSubsite?: boolean;
|
||||||
|
refreshedCookies?: string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
// requireAuthHook.js
|
// requireAuthHook.js
|
||||||
import { API_URL } from "@/config";
|
import { API_URL } from "@/config";
|
||||||
|
|
||||||
|
// WeakMap to cache auth promises per Astro context (request)
|
||||||
|
const authCache = new WeakMap();
|
||||||
|
|
||||||
export const requireAuthHook = async (Astro) => {
|
export const requireAuthHook = async (Astro) => {
|
||||||
|
// Check if we already have a cached promise for this request
|
||||||
|
if (authCache.has(Astro)) {
|
||||||
|
return authCache.get(Astro);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a promise and cache it immediately to prevent race conditions
|
||||||
|
const authPromise = performAuth(Astro);
|
||||||
|
authCache.set(Astro, authPromise);
|
||||||
|
|
||||||
|
// Return the promise - all callers will await the same promise
|
||||||
|
return authPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function performAuth(Astro) {
|
||||||
try {
|
try {
|
||||||
const cookieHeader = Astro.request.headers.get("cookie") ?? "";
|
const cookieHeader = Astro.request.headers.get("cookie") ?? "";
|
||||||
let res = await fetch(`${API_URL}/auth/id`, {
|
let res = await fetch(`${API_URL}/auth/id`, {
|
||||||
@@ -21,19 +38,30 @@ export const requireAuthHook = async (Astro) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
// Get all Set-Cookie headers (getSetCookie returns an array)
|
||||||
let newCookieHeader = cookieHeader;
|
let setCookies = [];
|
||||||
|
if (typeof refreshRes.headers.getSetCookie === 'function') {
|
||||||
if (setCookieHeader) {
|
setCookies = refreshRes.headers.getSetCookie();
|
||||||
const cookiesArray = setCookieHeader.split(/,(?=\s*\w+=)/);
|
|
||||||
cookiesArray.forEach((c) => Astro.response.headers.append("set-cookie", c));
|
|
||||||
|
|
||||||
newCookieHeader = cookiesArray.map(c => c.split(";")[0]).join("; ");
|
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback for older Node versions
|
||||||
|
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
||||||
|
if (setCookieHeader) {
|
||||||
|
// Split on comma followed by a cookie name (word=), avoiding splitting on Expires dates
|
||||||
|
setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setCookies.length === 0) {
|
||||||
console.error("No set-cookie header found in refresh response");
|
console.error("No set-cookie header found in refresh response");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward cookies to client
|
||||||
|
setCookies.forEach((c) => Astro.response.headers.append("set-cookie", c.trim()));
|
||||||
|
|
||||||
|
// Build new cookie header for the retry request
|
||||||
|
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
||||||
|
|
||||||
res = await fetch(`${API_URL}/auth/id`, {
|
res = await fetch(`${API_URL}/auth/id`, {
|
||||||
headers: { Cookie: newCookieHeader },
|
headers: { Cookie: newCookieHeader },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -52,4 +80,4 @@ export const requireAuthHook = async (Astro) => {
|
|||||||
console.error("[SSR] requireAuthHook error:", err);
|
console.error("[SSR] requireAuthHook error:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ interface Props {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
|
hideFooter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
import Themes from "astro-themes";
|
import Themes from "astro-themes";
|
||||||
@@ -13,6 +14,8 @@ import Nav from "./Nav.astro";
|
|||||||
import SubNav from "./SubNav.astro";
|
import SubNav from "./SubNav.astro";
|
||||||
import Footer from "../components/Footer.astro";
|
import Footer from "../components/Footer.astro";
|
||||||
|
|
||||||
|
import "@fontsource/ibm-plex-sans/500.css";
|
||||||
|
import "@fontsource/ibm-plex-sans/600.css";
|
||||||
import "@fontsource/geist-sans/400.css";
|
import "@fontsource/geist-sans/400.css";
|
||||||
import "@fontsource/geist-sans/600.css";
|
import "@fontsource/geist-sans/600.css";
|
||||||
import "@fontsource/geist-mono/400.css";
|
import "@fontsource/geist-mono/400.css";
|
||||||
@@ -20,7 +23,7 @@ import "@fontsource/geist-mono/600.css";
|
|||||||
import "@styles/global.css";
|
import "@styles/global.css";
|
||||||
import "@fonts/fonts.css";
|
import "@fonts/fonts.css";
|
||||||
|
|
||||||
const { title, description, image } = Astro.props;
|
const { title, description, image, hideFooter = false } = Astro.props;
|
||||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||||
const host = hostHeader.split(':')[0];
|
const host = hostHeader.split(':')[0];
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ if (import.meta.env.DEV) {
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="antialiased flex flex-col items-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
|
class="antialiased flex flex-col items-center mx-auto min-h-screen
|
||||||
scrollbar-hide"
|
scrollbar-hide"
|
||||||
style={`--brand-color: ${whitelabel?.brandColor ?? '#111827'}`}>
|
style={`--brand-color: ${whitelabel?.brandColor ?? '#111827'}`}>
|
||||||
|
|
||||||
@@ -86,14 +89,14 @@ if (import.meta.env.DEV) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
class="page-enter flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-3xl w-full">
|
class="page-enter flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-8">
|
||||||
<noscript>
|
<noscript>
|
||||||
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
||||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
<slot />
|
<slot />
|
||||||
<Footer />
|
{!hideFooter && <Footer />}
|
||||||
</main>
|
</main>
|
||||||
<style>
|
<style>
|
||||||
/* Minimal page transition to replace deprecated ViewTransitions */
|
/* Minimal page transition to replace deprecated ViewTransitions */
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ const currentPath = Astro.url.pathname;
|
|||||||
|
|
||||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||||
|
|
||||||
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
<nav class="w-full px-4 sm:px-6 py-3 sticky top-0 z-50 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||||
<!-- Logo/Brand -->
|
<!-- Logo/Brand -->
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity">
|
class="text-xl sm:text-2xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent whitespace-nowrap hover:opacity-80 transition-opacity font-['IBM_Plex_Sans',sans-serif]">
|
||||||
{metaData.title}
|
{metaData.title}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -76,10 +76,9 @@ const currentPath = Astro.url.pathname;
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-white"
|
? "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||||||
: "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
: "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"
|
||||||
}
|
}
|
||||||
style={isActive ? `background: #111827` : undefined}
|
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ const currentPath = Astro.url.pathname;
|
|||||||
|
|
||||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||||
|
|
||||||
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
<nav class="w-full px-4 sm:px-6 py-3 sticky top-0 z-50 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<a href="/" class="text-xl sm:text-2xl font-semibold" style={`color: ${whitelabel?.brandColor ?? 'var(--brand-color)'}`}>
|
<a href="/" class="text-xl sm:text-2xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent whitespace-nowrap hover:opacity-80 transition-opacity font-['IBM_Plex_Sans',sans-serif]">
|
||||||
{whitelabel?.logoText ?? 'Subsite'}
|
{whitelabel?.logoText ?? 'Subsite'}
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import "@/assets/styles/nav.css";
|
|||||||
|
|
||||||
const whitelabel = Astro.props?.whitelabel ?? null;
|
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
const activeColor = "#111827";
|
|
||||||
const subsitePathPrefix = "/subsites/req";
|
const subsitePathPrefix = "/subsites/req";
|
||||||
|
|
||||||
const user = await requireAuthHook(Astro);
|
const user = await requireAuthHook(Astro);
|
||||||
@@ -46,12 +45,12 @@ const normalizedCurrent = normalize(trimmedPath);
|
|||||||
|
|
||||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||||
|
|
||||||
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
<nav class="w-full px-4 sm:px-6 py-3 sticky top-0 z-50 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity"
|
class="text-xl sm:text-2xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent whitespace-nowrap hover:opacity-80 transition-opacity font-['IBM_Plex_Sans',sans-serif]"
|
||||||
>
|
>
|
||||||
{whitelabel?.logoText ?? 'REQ'}
|
{whitelabel?.logoText ?? 'REQ'}
|
||||||
</a>
|
</a>
|
||||||
@@ -73,9 +72,8 @@ const normalizedCurrent = normalize(trimmedPath);
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-white"
|
? "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||||||
: "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}
|
: "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"}
|
||||||
style={isActive ? `background: ${activeColor}` : undefined}
|
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
@@ -158,9 +156,8 @@ const normalizedCurrent = normalize(trimmedPath);
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white"
|
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 font-['IBM_Plex_Sans',sans-serif]"
|
||||||
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}
|
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 font-['IBM_Plex_Sans',sans-serif]"}
|
||||||
style={isActive ? `background: ${activeColor}` : undefined}
|
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
|
|||||||
@@ -1,9 +1,164 @@
|
|||||||
import { defineMiddleware } from 'astro:middleware';
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
import { SUBSITES } from './config.js';
|
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES } from './config.js';
|
||||||
import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js';
|
import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js';
|
||||||
|
|
||||||
|
const API_URL = "https://api.codey.lol";
|
||||||
|
|
||||||
|
// Auth check function (mirrors requireAuthHook logic but for middleware)
|
||||||
|
async function checkAuth(request) {
|
||||||
|
try {
|
||||||
|
const cookieHeader = request.headers.get("cookie") ?? "";
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Cookie header present: ${!!cookieHeader}`);
|
||||||
|
|
||||||
|
let res = await fetch(`${API_URL}/auth/id`, {
|
||||||
|
headers: { Cookie: cookieHeader },
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware:checkAuth] Initial auth/id status: ${res.status}`);
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Try refresh
|
||||||
|
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Cookie: cookieHeader },
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshRes.ok) {
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refreshed cookies
|
||||||
|
let setCookies = [];
|
||||||
|
if (typeof refreshRes.headers.getSetCookie === 'function') {
|
||||||
|
setCookies = refreshRes.headers.getSetCookie();
|
||||||
|
} else {
|
||||||
|
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
||||||
|
if (setCookieHeader) {
|
||||||
|
setCookies = setCookieHeader.split(/,(?=\s*[a-zA-Z_][a-zA-Z0-9_]*=)/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setCookies.length === 0) {
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new cookie header for retry
|
||||||
|
const newCookieHeader = setCookies.map(c => c.split(";")[0].trim()).join("; ");
|
||||||
|
|
||||||
|
res = await fetch(`${API_URL}/auth/id`, {
|
||||||
|
headers: { Cookie: newCookieHeader },
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await res.json();
|
||||||
|
return { authenticated: true, user, cookies: setCookies };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await res.json();
|
||||||
|
return { authenticated: true, user, cookies: null };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[middleware] Auth check error:", err);
|
||||||
|
return { authenticated: false, user: null, cookies: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a path matches any protected route and return the config
|
||||||
|
function getProtectedRouteConfig(pathname) {
|
||||||
|
// Normalize pathname for comparison (lowercase)
|
||||||
|
const normalizedPath = pathname.toLowerCase();
|
||||||
|
|
||||||
|
for (const route of PROTECTED_ROUTES) {
|
||||||
|
const routePath = typeof route === 'string' ? route : route.path;
|
||||||
|
const normalizedRoute = routePath.toLowerCase();
|
||||||
|
const matches = normalizedPath === normalizedRoute || normalizedPath.startsWith(normalizedRoute + '/');
|
||||||
|
if (matches) {
|
||||||
|
return typeof route === 'string' ? { path: route, roles: null } : route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a path is explicitly public
|
||||||
|
function isPublicRoute(pathname) {
|
||||||
|
for (const route of PUBLIC_ROUTES) {
|
||||||
|
// For routes ending with /, match any path starting with that prefix
|
||||||
|
if (route.endsWith('/') && route !== '/') {
|
||||||
|
if (pathname.startsWith(route)) {
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route} (prefix)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other routes, match exactly or as a path prefix (route + /)
|
||||||
|
if (pathname === route) {
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route} (exact)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Special case: don't treat / as a prefix for all paths
|
||||||
|
if (route !== '/' && pathname.startsWith(route + '/')) {
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute: ${pathname} matched ${route}/ (subpath)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] isPublicRoute(${pathname}) = false`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export const onRequest = defineMiddleware(async (context, next) => {
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
try {
|
try {
|
||||||
|
const pathname = context.url.pathname;
|
||||||
|
|
||||||
|
// Skip auth check for static assets and API routes
|
||||||
|
const skipAuthPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/images', '/_static'];
|
||||||
|
const shouldSkipAuth = skipAuthPrefixes.some(p => pathname.startsWith(p));
|
||||||
|
|
||||||
|
// Check authentication for protected routes
|
||||||
|
const protectedConfig = getProtectedRouteConfig(pathname);
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] Path: ${pathname}, Protected: ${!!protectedConfig}, SkipAuth: ${shouldSkipAuth}`);
|
||||||
|
|
||||||
|
if (!shouldSkipAuth && protectedConfig && !isPublicRoute(pathname)) {
|
||||||
|
const { authenticated, user, cookies } = await checkAuth(context.request);
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] Auth result: authenticated=${authenticated}`);
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
// Redirect to login with return URL
|
||||||
|
const returnUrl = encodeURIComponent(pathname + context.url.search);
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] Auth required for ${pathname}, redirecting to login`);
|
||||||
|
return context.redirect(`/login?returnUrl=${returnUrl}`, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role-based access if roles are specified
|
||||||
|
if (protectedConfig.roles && protectedConfig.roles.length > 0) {
|
||||||
|
const userRoles = user?.roles || [];
|
||||||
|
const hasRequiredRole = protectedConfig.roles.some(role => userRoles.includes(role));
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] User lacks required role for ${pathname}, redirecting to login`);
|
||||||
|
return context.redirect('/login', 302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user in locals for downstream use
|
||||||
|
context.locals.user = user;
|
||||||
|
|
||||||
|
// Forward any refreshed cookies
|
||||||
|
if (cookies && cookies.length > 0) {
|
||||||
|
context.locals.refreshedCookies = cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) console.log(`[middleware] Auth OK for ${pathname}, user: ${user?.username || user?.id || 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Check the Host header to differentiate subdomains
|
// Check the Host header to differentiate subdomains
|
||||||
// Build a headers map safely because Headers.get(':authority') throws
|
// Build a headers map safely because Headers.get(':authority') throws
|
||||||
const headersMap = {};
|
const headersMap = {};
|
||||||
@@ -30,7 +185,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
|
|
||||||
// Debug info for incoming requests
|
// Debug info for incoming requests
|
||||||
if (import.meta.env.DEV) console.log(`[middleware] incoming: host=${hostHeader} ip=${requestIp} path=${context.url.pathname}`);
|
if (import.meta.env.DEV) console.log(`[middleware] incoming: host=${hostHeader} ip=${requestIp} path=${context.url.pathname}`);
|
||||||
else console.log(`[middleware] request from ${requestIp}${cfCountry ? ` (${cfCountry})` : ''}${userAgent ? ` [${userAgent}]` : ''}: ${context.url.pathname}`);
|
// else console.log(`[middleware] request from ${requestIp}${cfCountry ? ` (${cfCountry})` : ''}${userAgent ? ` [${userAgent}]` : ''}: ${context.url.pathname}`);
|
||||||
|
|
||||||
// When the host header is missing, log all headers for debugging and
|
// When the host header is missing, log all headers for debugging and
|
||||||
// attempt to determine host from forwarded headers or a dev query header.
|
// attempt to determine host from forwarded headers or a dev query header.
|
||||||
@@ -121,15 +276,82 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
// Let Astro handle the request first
|
// Let Astro handle the request first
|
||||||
const response = await next();
|
const response = await next();
|
||||||
|
|
||||||
|
// Add security headers to response
|
||||||
|
const securityHeaders = new Headers(response.headers);
|
||||||
|
|
||||||
|
// Prevent clickjacking
|
||||||
|
securityHeaders.set('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
|
||||||
|
// Prevent MIME type sniffing
|
||||||
|
securityHeaders.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
|
// XSS protection (legacy, but still useful)
|
||||||
|
securityHeaders.set('X-XSS-Protection', '1; mode=block');
|
||||||
|
|
||||||
|
// Referrer policy - send origin only on cross-origin requests
|
||||||
|
securityHeaders.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
|
||||||
|
// HSTS - enforce HTTPS (only in production)
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
securityHeaders.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions policy - restrict sensitive APIs
|
||||||
|
securityHeaders.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
|
|
||||||
|
// Content Security Policy - restrict resource loading
|
||||||
|
// Note: 'unsafe-inline' and 'unsafe-eval' needed for React/Astro hydration
|
||||||
|
// In production, consider using nonces or hashes for stricter CSP
|
||||||
|
const cspDirectives = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.youtube.com https://www.youtube-nocookie.com https://s.ytimg.com",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
|
"img-src 'self' data: blob: https: http:",
|
||||||
|
"media-src 'self' blob: https:",
|
||||||
|
"connect-src 'self' https://api.codey.lol https://*.codey.lol wss:",
|
||||||
|
// Allow YouTube for video embeds
|
||||||
|
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
"upgrade-insecure-requests",
|
||||||
|
].join('; ');
|
||||||
|
securityHeaders.set('Content-Security-Policy', cspDirectives);
|
||||||
|
|
||||||
|
// Forward any refreshed auth cookies to the client
|
||||||
|
if (context.locals.refreshedCookies && context.locals.refreshedCookies.length > 0) {
|
||||||
|
const newResponse = new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: securityHeaders,
|
||||||
|
});
|
||||||
|
context.locals.refreshedCookies.forEach(cookie => {
|
||||||
|
newResponse.headers.append('set-cookie', cookie.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
// If it's a 404, redirect to home
|
||||||
|
if (newResponse.status === 404 && !context.url.pathname.startsWith('/api/')) {
|
||||||
|
if (import.meta.env.DEV) console.log(`404 redirect: ${context.url.pathname} -> /`);
|
||||||
|
return context.redirect('/', 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newResponse;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a 404, redirect to home
|
// If it's a 404, redirect to home
|
||||||
if (response.status === 404 && !context.url.pathname.startsWith('/api/')) {
|
if (response.status === 404 && !context.url.pathname.startsWith('/api/')) {
|
||||||
if (import.meta.env.DEV) console.log(`404 redirect: ${context.url.pathname} -> /`);
|
if (import.meta.env.DEV) console.log(`404 redirect: ${context.url.pathname} -> /`);
|
||||||
return context.redirect('/', 302);
|
return context.redirect('/', 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
// no post-processing of response
|
// Return response with security headers
|
||||||
|
return new Response(response.body, {
|
||||||
return response;
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: securityHeaders,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle any middleware errors by redirecting to home
|
// Handle any middleware errors by redirecting to home
|
||||||
console.error('Middleware error:', error);
|
console.error('Middleware error:', error);
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
---
|
---
|
||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
import Root from "@/components/AppLayout.jsx";
|
import Root from "@/components/AppLayout.jsx";
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
|
||||||
|
|
||||||
const user = await requireAuthHook(Astro);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return Astro.redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Auth is handled by middleware - user available in Astro.locals.user
|
||||||
|
// Middleware redirects to /login if not authenticated
|
||||||
|
const user = Astro.locals.user as any;
|
||||||
---
|
---
|
||||||
<Base>
|
<Base>
|
||||||
<section>
|
<section class="page-section trip-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="qs2.MediaRequestForm" client:only="react" />
|
||||||
<Root child="qs2.MediaRequestForm" client:only="react">
|
|
||||||
</Root>
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
/* Override main container width for TRip pages */
|
||||||
|
body:has(.trip-section) main.page-enter {
|
||||||
|
max-width: 1400px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
---
|
---
|
||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
import Root from "@/components/AppLayout.jsx";
|
import Root from "@/components/AppLayout.jsx";
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
|
||||||
|
|
||||||
const user = await requireAuthHook(Astro);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return Astro.redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Auth is handled by middleware - user available in Astro.locals.user
|
||||||
|
// Middleware redirects to /login if not authenticated
|
||||||
|
const user = Astro.locals.user as any;
|
||||||
---
|
---
|
||||||
<Base>
|
<Base>
|
||||||
<section>
|
<section class="page-section trip-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="qs2.RequestManagement" client:only="react" />
|
||||||
<Root child="qs2.RequestManagement" client:only="react">
|
|
||||||
</Root>
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
/* Override main container width for TRip pages */
|
||||||
|
html:has(.trip-section) main.page-enter {
|
||||||
|
max-width: 1400px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,16 +2,74 @@
|
|||||||
* API endpoint to serve cached images from the database
|
* API endpoint to serve cached images from the database
|
||||||
* Serves avatars, emojis, attachments, etc. from local cache
|
* Serves avatars, emojis, attachments, etc. from local cache
|
||||||
*
|
*
|
||||||
* Note: This endpoint is intentionally unauthenticated because:
|
* Security: Uses HMAC signatures to prevent enumeration of image IDs.
|
||||||
* 1. Image tags don't reliably send auth cookies on initial load
|
* Images can only be accessed with a valid signature generated server-side.
|
||||||
* 2. Image IDs are not guessable (you need access to the messages API first)
|
|
||||||
* 3. The underlying Discord images are semi-public anyway
|
|
||||||
*/
|
*/
|
||||||
import sql from '../../../utils/db.js';
|
import sql from '../../../utils/db.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest,
|
||||||
|
} from '../../../utils/rateLimit.js';
|
||||||
|
|
||||||
|
// Secret for signing image IDs - prevents enumeration attacks
|
||||||
|
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
|
||||||
|
if (!IMAGE_CACHE_SECRET) {
|
||||||
|
console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HMAC signature for an image ID
|
||||||
|
* @param {string|number} imageId - The image ID to sign
|
||||||
|
* @returns {string} - The hex signature
|
||||||
|
*/
|
||||||
|
export function signImageId(imageId) {
|
||||||
|
if (!IMAGE_CACHE_SECRET) {
|
||||||
|
throw new Error('IMAGE_CACHE_SECRET not configured');
|
||||||
|
}
|
||||||
|
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
||||||
|
hmac.update(String(imageId));
|
||||||
|
return hmac.digest('hex').substring(0, 16); // Short signature is sufficient
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify HMAC signature for an image ID
|
||||||
|
* @param {string|number} imageId - The image ID
|
||||||
|
* @param {string} signature - The signature to verify
|
||||||
|
* @returns {boolean} - Whether signature is valid
|
||||||
|
*/
|
||||||
|
function verifyImageSignature(imageId, signature) {
|
||||||
|
if (!IMAGE_CACHE_SECRET || !signature) return false;
|
||||||
|
const expected = signImageId(imageId);
|
||||||
|
// Timing-safe comparison
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
|
// Rate limit check - higher limit for images but still protected
|
||||||
|
const rateCheck = checkRateLimit(request, {
|
||||||
|
limit: 100,
|
||||||
|
windowMs: 1000,
|
||||||
|
burstLimit: 500,
|
||||||
|
burstWindowMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
return new Response('Rate limit exceeded', {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': '1' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest(request, 1000);
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const imageId = url.searchParams.get('id');
|
const imageId = url.searchParams.get('id');
|
||||||
|
const signature = url.searchParams.get('sig');
|
||||||
const sourceUrl = url.searchParams.get('url');
|
const sourceUrl = url.searchParams.get('url');
|
||||||
|
|
||||||
if (!imageId && !sourceUrl) {
|
if (!imageId && !sourceUrl) {
|
||||||
@@ -23,11 +81,16 @@ export async function GET({ request }) {
|
|||||||
return new Response('Invalid image id', { status: 400 });
|
return new Response('Invalid image id', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require valid signature for ID-based lookups to prevent enumeration
|
||||||
|
if (imageId && !verifyImageSignature(imageId, signature)) {
|
||||||
|
return new Response('Invalid or missing signature', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let image;
|
let image;
|
||||||
|
|
||||||
if (imageId) {
|
if (imageId) {
|
||||||
// Look up by image_id
|
// Look up by image_id (signature already verified above)
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT image_data, content_type, source_url
|
SELECT image_data, content_type, source_url
|
||||||
FROM image_cache
|
FROM image_cache
|
||||||
@@ -35,7 +98,7 @@ export async function GET({ request }) {
|
|||||||
`;
|
`;
|
||||||
image = result[0];
|
image = result[0];
|
||||||
} else {
|
} else {
|
||||||
// Look up by source_url
|
// Look up by source_url - no signature needed as URL itself is the identifier
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT image_data, content_type, source_url
|
SELECT image_data, content_type, source_url
|
||||||
FROM image_cache
|
FROM image_cache
|
||||||
|
|||||||
@@ -3,8 +3,31 @@
|
|||||||
*/
|
*/
|
||||||
import sql from '../../../utils/db.js';
|
import sql from '../../../utils/db.js';
|
||||||
import { requireApiAuth, createApiResponse } from '../../../utils/apiAuth.js';
|
import { requireApiAuth, createApiResponse } from '../../../utils/apiAuth.js';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest,
|
||||||
|
} from '../../../utils/rateLimit.js';
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
|
// Rate limit check
|
||||||
|
const rateCheck = checkRateLimit(request, {
|
||||||
|
limit: 20,
|
||||||
|
windowMs: 1000,
|
||||||
|
burstLimit: 100,
|
||||||
|
burstWindowMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest(request, 1000);
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
@@ -24,11 +47,18 @@ export async function GET({ request }) {
|
|||||||
let channels;
|
let channels;
|
||||||
|
|
||||||
// Channel types: 0=text, 2=voice, 4=category, 5=announcement, 10/11/12=threads
|
// Channel types: 0=text, 2=voice, 4=category, 5=announcement, 10/11/12=threads
|
||||||
// Fetch text channels (type 0) and categories (type 4) for grouping
|
// Fetch text channels (type 0), categories (type 4), and threads (types 10, 11, 12)
|
||||||
|
|
||||||
if (guildId) {
|
if (guildId) {
|
||||||
// Fetch channels for specific guild with message counts
|
// Fetch channels for specific guild with message counts
|
||||||
|
// Use LEFT JOIN with aggregated counts to avoid N+1 subqueries
|
||||||
channels = await sql`
|
channels = await sql`
|
||||||
|
WITH channel_counts AS (
|
||||||
|
SELECT channel_id, COUNT(*) as message_count
|
||||||
|
FROM messages
|
||||||
|
WHERE is_deleted = FALSE
|
||||||
|
GROUP BY channel_id
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
c.channel_id,
|
c.channel_id,
|
||||||
c.name,
|
c.name,
|
||||||
@@ -39,16 +69,24 @@ export async function GET({ request }) {
|
|||||||
c.guild_id,
|
c.guild_id,
|
||||||
g.name as guild_name,
|
g.name as guild_name,
|
||||||
g.icon_url as guild_icon,
|
g.icon_url as guild_icon,
|
||||||
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.channel_id AND m.is_deleted = FALSE) as message_count
|
COALESCE(cc.message_count, 0) as message_count
|
||||||
FROM channels c
|
FROM channels c
|
||||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||||
|
LEFT JOIN channel_counts cc ON c.channel_id = cc.channel_id
|
||||||
WHERE c.guild_id = ${guildId}
|
WHERE c.guild_id = ${guildId}
|
||||||
AND c.type IN (0, 4)
|
AND c.type IN (0, 4, 10, 11, 12)
|
||||||
ORDER BY c.position ASC, c.name ASC
|
ORDER BY c.position ASC, c.name ASC
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
// Fetch all text channels and categories with message counts
|
// Fetch all text channels, categories, and threads with message counts
|
||||||
|
// Use LEFT JOIN with aggregated counts to avoid N+1 subqueries
|
||||||
channels = await sql`
|
channels = await sql`
|
||||||
|
WITH channel_counts AS (
|
||||||
|
SELECT channel_id, COUNT(*) as message_count
|
||||||
|
FROM messages
|
||||||
|
WHERE is_deleted = FALSE
|
||||||
|
GROUP BY channel_id
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
c.channel_id,
|
c.channel_id,
|
||||||
c.name,
|
c.name,
|
||||||
@@ -59,10 +97,11 @@ export async function GET({ request }) {
|
|||||||
c.guild_id,
|
c.guild_id,
|
||||||
g.name as guild_name,
|
g.name as guild_name,
|
||||||
g.icon_url as guild_icon,
|
g.icon_url as guild_icon,
|
||||||
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.channel_id AND m.is_deleted = FALSE) as message_count
|
COALESCE(cc.message_count, 0) as message_count
|
||||||
FROM channels c
|
FROM channels c
|
||||||
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
LEFT JOIN guilds g ON c.guild_id = g.guild_id
|
||||||
WHERE c.type IN (0, 4)
|
LEFT JOIN channel_counts cc ON c.channel_id = cc.channel_id
|
||||||
|
WHERE c.type IN (0, 4, 10, 11, 12)
|
||||||
ORDER BY g.name ASC, c.position ASC, c.name ASC
|
ORDER BY g.name ASC, c.position ASC, c.name ASC
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
import sql from '../../../utils/db.js';
|
import sql from '../../../utils/db.js';
|
||||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest,
|
||||||
|
} from '../../../utils/rateLimit.js';
|
||||||
|
import { signImageId } from './cached-image.js';
|
||||||
|
|
||||||
// Discord permission flags
|
// Discord permission flags
|
||||||
const VIEW_CHANNEL = 0x400n; // 1024
|
const VIEW_CHANNEL = 0x400n; // 1024
|
||||||
@@ -131,6 +136,25 @@ async function getChannelVisibleMembers(channelId, guildId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
|
// Rate limit check
|
||||||
|
const rateCheck = checkRateLimit(request, {
|
||||||
|
limit: 20,
|
||||||
|
windowMs: 1000,
|
||||||
|
burstLimit: 100,
|
||||||
|
burstWindowMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest(request, 1000);
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
@@ -247,7 +271,8 @@ export async function GET({ request }) {
|
|||||||
const cachedAvatarId = member.cached_guild_avatar_id || member.cached_avatar_id;
|
const cachedAvatarId = member.cached_guild_avatar_id || member.cached_avatar_id;
|
||||||
let avatarUrl;
|
let avatarUrl;
|
||||||
if (cachedAvatarId) {
|
if (cachedAvatarId) {
|
||||||
avatarUrl = `/api/discord/cached-image?id=${cachedAvatarId}`;
|
const sig = signImageId(cachedAvatarId);
|
||||||
|
avatarUrl = `/api/discord/cached-image?id=${cachedAvatarId}&sig=${sig}`;
|
||||||
} else {
|
} else {
|
||||||
avatarUrl = member.guild_avatar_url || member.avatar_url;
|
avatarUrl = member.guild_avatar_url || member.avatar_url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
import sql from '../../../utils/db.js';
|
import sql from '../../../utils/db.js';
|
||||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest,
|
||||||
|
} from '../../../utils/rateLimit.js';
|
||||||
|
import { signImageId } from './cached-image.js';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
const IMAGE_PROXY_SECRET = process.env.IMAGE_PROXY_SECRET || 'dev-secret-change-me';
|
const IMAGE_PROXY_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||||
|
if (!IMAGE_PROXY_SECRET) {
|
||||||
|
console.error('WARNING: IMAGE_PROXY_SECRET not set, image signing will fail');
|
||||||
|
}
|
||||||
|
|
||||||
// Trusted domains that don't need proxying
|
// Trusted domains that don't need proxying
|
||||||
const TRUSTED_DOMAINS = new Set([
|
const TRUSTED_DOMAINS = new Set([
|
||||||
@@ -57,7 +65,8 @@ function generateSignature(url) {
|
|||||||
function getCachedOrProxyUrl(cachedImageId, originalUrl, baseUrl) {
|
function getCachedOrProxyUrl(cachedImageId, originalUrl, baseUrl) {
|
||||||
// Prefer cached image if available
|
// Prefer cached image if available
|
||||||
if (cachedImageId) {
|
if (cachedImageId) {
|
||||||
return `${baseUrl}/api/discord/cached-image?id=${cachedImageId}`;
|
const sig = signImageId(cachedImageId);
|
||||||
|
return `${baseUrl}/api/discord/cached-image?id=${cachedImageId}&sig=${sig}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to original URL with proxy if needed
|
// Fall back to original URL with proxy if needed
|
||||||
@@ -85,6 +94,25 @@ function getSafeImageUrl(originalUrl, baseUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
|
// Rate limit check
|
||||||
|
const rateCheck = checkRateLimit(request, {
|
||||||
|
limit: 30,
|
||||||
|
windowMs: 1000,
|
||||||
|
burstLimit: 150,
|
||||||
|
burstWindowMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest(request, 1000);
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
@@ -108,6 +136,7 @@ export async function GET({ request }) {
|
|||||||
const before = url.searchParams.get('before'); // For pagination
|
const before = url.searchParams.get('before'); // For pagination
|
||||||
const after = url.searchParams.get('after');
|
const after = url.searchParams.get('after');
|
||||||
const around = url.searchParams.get('around'); // For jumping to specific message
|
const around = url.searchParams.get('around'); // For jumping to specific message
|
||||||
|
const editedSince = url.searchParams.get('editedSince'); // ISO timestamp for fetching recently edited messages
|
||||||
|
|
||||||
const baseUrl = `${url.protocol}//${url.host}`;
|
const baseUrl = `${url.protocol}//${url.host}`;
|
||||||
|
|
||||||
@@ -130,10 +159,54 @@ export async function GET({ request }) {
|
|||||||
// Joins guild_members for nickname and gets highest role color
|
// Joins guild_members for nickname and gets highest role color
|
||||||
let messages;
|
let messages;
|
||||||
|
|
||||||
if (before) {
|
if (editedSince) {
|
||||||
|
// Fetch messages edited since the given timestamp
|
||||||
|
const editedSinceDate = new Date(editedSince);
|
||||||
messages = await sql`
|
messages = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
m.message_id,
|
m.message_id,
|
||||||
|
m.raw_data,
|
||||||
|
m.message_type,
|
||||||
|
m.content,
|
||||||
|
m.created_at,
|
||||||
|
m.edited_at,
|
||||||
|
m.reference_message_id,
|
||||||
|
m.channel_id,
|
||||||
|
m.webhook_id,
|
||||||
|
m.reference_guild_id,
|
||||||
|
c.guild_id as message_guild_id,
|
||||||
|
u.user_id as author_id,
|
||||||
|
u.username as author_username,
|
||||||
|
u.discriminator as author_discriminator,
|
||||||
|
u.avatar_url as author_avatar,
|
||||||
|
u.is_bot as author_bot,
|
||||||
|
COALESCE(gm.nickname, u.global_name, u.username) as author_display_name,
|
||||||
|
COALESCE(gm.guild_avatar_url, u.avatar_url) as author_guild_avatar,
|
||||||
|
(
|
||||||
|
SELECT r.color
|
||||||
|
FROM roles r
|
||||||
|
WHERE r.role_id = ANY(gm.roles)
|
||||||
|
AND r.color IS NOT NULL
|
||||||
|
AND r.color != 0
|
||||||
|
ORDER BY r.position DESC
|
||||||
|
LIMIT 1
|
||||||
|
) as author_color
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN users u ON m.author_id = u.user_id
|
||||||
|
LEFT JOIN channels c ON m.channel_id = c.channel_id
|
||||||
|
LEFT JOIN guild_members gm ON u.user_id = gm.user_id AND c.guild_id = gm.guild_id
|
||||||
|
WHERE m.channel_id = ${channelId}
|
||||||
|
AND m.edited_at > ${editedSinceDate}
|
||||||
|
AND m.is_deleted = FALSE
|
||||||
|
ORDER BY m.edited_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
} else if (before) {
|
||||||
|
messages = await sql`
|
||||||
|
SELECT
|
||||||
|
m.message_id,
|
||||||
|
m.raw_data,
|
||||||
|
m.message_type,
|
||||||
m.content,
|
m.content,
|
||||||
m.created_at,
|
m.created_at,
|
||||||
m.edited_at,
|
m.edited_at,
|
||||||
@@ -169,6 +242,8 @@ export async function GET({ request }) {
|
|||||||
messages = await sql`
|
messages = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
m.message_id,
|
m.message_id,
|
||||||
|
m.raw_data,
|
||||||
|
m.message_type,
|
||||||
m.content,
|
m.content,
|
||||||
m.created_at,
|
m.created_at,
|
||||||
m.edited_at,
|
m.edited_at,
|
||||||
@@ -210,6 +285,8 @@ export async function GET({ request }) {
|
|||||||
(
|
(
|
||||||
SELECT
|
SELECT
|
||||||
m.message_id,
|
m.message_id,
|
||||||
|
m.raw_data,
|
||||||
|
m.message_type,
|
||||||
m.content,
|
m.content,
|
||||||
m.created_at,
|
m.created_at,
|
||||||
m.edited_at,
|
m.edited_at,
|
||||||
@@ -248,6 +325,7 @@ export async function GET({ request }) {
|
|||||||
(
|
(
|
||||||
SELECT
|
SELECT
|
||||||
m.message_id,
|
m.message_id,
|
||||||
|
m.message_type,
|
||||||
m.content,
|
m.content,
|
||||||
m.created_at,
|
m.created_at,
|
||||||
m.edited_at,
|
m.edited_at,
|
||||||
@@ -257,9 +335,6 @@ export async function GET({ request }) {
|
|||||||
m.reference_guild_id,
|
m.reference_guild_id,
|
||||||
c.guild_id as message_guild_id,
|
c.guild_id as message_guild_id,
|
||||||
u.user_id as author_id,
|
u.user_id as author_id,
|
||||||
m.reference_guild_id,
|
|
||||||
c.guild_id as message_guild_id,
|
|
||||||
u.user_id as author_id,
|
|
||||||
u.username as author_username,
|
u.username as author_username,
|
||||||
u.discriminator as author_discriminator,
|
u.discriminator as author_discriminator,
|
||||||
u.avatar_url as author_avatar,
|
u.avatar_url as author_avatar,
|
||||||
@@ -291,6 +366,8 @@ export async function GET({ request }) {
|
|||||||
messages = await sql`
|
messages = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
m.message_id,
|
m.message_id,
|
||||||
|
m.raw_data,
|
||||||
|
m.message_type,
|
||||||
m.content,
|
m.content,
|
||||||
m.created_at,
|
m.created_at,
|
||||||
m.edited_at,
|
m.edited_at,
|
||||||
@@ -300,9 +377,6 @@ export async function GET({ request }) {
|
|||||||
m.reference_guild_id,
|
m.reference_guild_id,
|
||||||
c.guild_id as message_guild_id,
|
c.guild_id as message_guild_id,
|
||||||
u.user_id as author_id,
|
u.user_id as author_id,
|
||||||
m.reference_guild_id,
|
|
||||||
c.guild_id as message_guild_id,
|
|
||||||
u.user_id as author_id,
|
|
||||||
u.username as author_username,
|
u.username as author_username,
|
||||||
u.discriminator as author_discriminator,
|
u.discriminator as author_discriminator,
|
||||||
u.avatar_url as author_avatar,
|
u.avatar_url as author_avatar,
|
||||||
@@ -420,6 +494,7 @@ export async function GET({ request }) {
|
|||||||
const embeds = await sql`
|
const embeds = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
embed_id, message_id, embed_type, title, description, url,
|
embed_id, message_id, embed_type, title, description, url,
|
||||||
|
raw_data,
|
||||||
color, timestamp as embed_timestamp,
|
color, timestamp as embed_timestamp,
|
||||||
footer_text, footer_icon_url, cached_footer_icon_id,
|
footer_text, footer_icon_url, cached_footer_icon_id,
|
||||||
image_url, image_width, image_height, cached_image_id,
|
image_url, image_width, image_height, cached_image_id,
|
||||||
@@ -431,6 +506,41 @@ export async function GET({ request }) {
|
|||||||
WHERE message_id = ANY(${messageIds})
|
WHERE message_id = ANY(${messageIds})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Fetch message components (buttons, selects, etc.) for all messages
|
||||||
|
const messageComponents = await sql`
|
||||||
|
SELECT
|
||||||
|
component_id, message_id, component_type, custom_id, label, style, url, disabled, placeholder,
|
||||||
|
min_values, max_values, raw_data
|
||||||
|
FROM message_components
|
||||||
|
WHERE message_id = ANY(${messageIds})
|
||||||
|
ORDER BY component_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fetch embed fields for all embeds
|
||||||
|
const embedIds = embeds.map(e => e.embed_id);
|
||||||
|
let embedFields = [];
|
||||||
|
if (embedIds.length > 0) {
|
||||||
|
embedFields = await sql`
|
||||||
|
SELECT embed_id, name, value, inline, position
|
||||||
|
FROM embed_fields
|
||||||
|
WHERE embed_id = ANY(${embedIds})
|
||||||
|
ORDER BY position ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index embed fields by embed_id
|
||||||
|
const fieldsByEmbed = {};
|
||||||
|
for (const field of embedFields) {
|
||||||
|
if (!fieldsByEmbed[field.embed_id]) {
|
||||||
|
fieldsByEmbed[field.embed_id] = [];
|
||||||
|
}
|
||||||
|
fieldsByEmbed[field.embed_id].push({
|
||||||
|
name: field.name,
|
||||||
|
value: field.value,
|
||||||
|
inline: field.inline || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch reactions for all messages (aggregate counts since each reaction is a row)
|
// Fetch reactions for all messages (aggregate counts since each reaction is a row)
|
||||||
const reactions = await sql`
|
const reactions = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -454,6 +564,58 @@ export async function GET({ request }) {
|
|||||||
WHERE ms.message_id = ANY(${messageIds})
|
WHERE ms.message_id = ANY(${messageIds})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Fetch polls for all messages
|
||||||
|
const polls = await sql`
|
||||||
|
SELECT
|
||||||
|
p.message_id,
|
||||||
|
p.question_text,
|
||||||
|
p.question_emoji_id,
|
||||||
|
p.question_emoji_name,
|
||||||
|
p.question_emoji_animated,
|
||||||
|
p.allow_multiselect,
|
||||||
|
p.expiry,
|
||||||
|
p.is_finalized,
|
||||||
|
p.total_votes
|
||||||
|
FROM polls p
|
||||||
|
WHERE p.message_id = ANY(${messageIds})
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fetch poll answers
|
||||||
|
const pollAnswers = await sql`
|
||||||
|
SELECT
|
||||||
|
pa.message_id,
|
||||||
|
pa.answer_id,
|
||||||
|
pa.answer_text,
|
||||||
|
pa.answer_emoji_id,
|
||||||
|
pa.answer_emoji_name,
|
||||||
|
pa.answer_emoji_animated,
|
||||||
|
pa.vote_count
|
||||||
|
FROM poll_answers pa
|
||||||
|
WHERE pa.message_id = ANY(${messageIds})
|
||||||
|
ORDER BY pa.answer_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fetch poll votes with user info
|
||||||
|
const pollMessageIds = polls.map(p => p.message_id);
|
||||||
|
let pollVotes = [];
|
||||||
|
if (pollMessageIds.length > 0) {
|
||||||
|
pollVotes = await sql`
|
||||||
|
SELECT
|
||||||
|
pv.message_id,
|
||||||
|
pv.answer_id,
|
||||||
|
pv.user_id,
|
||||||
|
u.username,
|
||||||
|
COALESCE(gm.nickname, u.global_name, u.username) as display_name,
|
||||||
|
COALESCE(gm.guild_avatar_url, u.avatar_url) as avatar_url
|
||||||
|
FROM poll_votes pv
|
||||||
|
LEFT JOIN users u ON pv.user_id = u.user_id
|
||||||
|
LEFT JOIN guild_members gm ON pv.user_id = gm.user_id AND gm.guild_id = ${guildId}
|
||||||
|
WHERE pv.message_id = ANY(${pollMessageIds})
|
||||||
|
AND pv.is_removed = FALSE
|
||||||
|
ORDER BY pv.voted_at ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch referenced messages for replies
|
// Fetch referenced messages for replies
|
||||||
const referencedIds = messages
|
const referencedIds = messages
|
||||||
.filter(m => m.reference_message_id)
|
.filter(m => m.reference_message_id)
|
||||||
@@ -483,6 +645,8 @@ export async function GET({ request }) {
|
|||||||
const embedsByMessage = {};
|
const embedsByMessage = {};
|
||||||
const reactionsByMessage = {};
|
const reactionsByMessage = {};
|
||||||
const stickersByMessage = {};
|
const stickersByMessage = {};
|
||||||
|
const componentsByMessage = {};
|
||||||
|
const pollsByMessage = {};
|
||||||
const referencedById = {};
|
const referencedById = {};
|
||||||
|
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
@@ -539,6 +703,8 @@ export async function GET({ request }) {
|
|||||||
url: embed.author_url,
|
url: embed.author_url,
|
||||||
iconUrl: getCachedOrProxyUrl(embed.cached_author_icon_id, embed.author_icon_url, baseUrl),
|
iconUrl: getCachedOrProxyUrl(embed.cached_author_icon_id, embed.author_icon_url, baseUrl),
|
||||||
} : null,
|
} : null,
|
||||||
|
fields: fieldsByEmbed[embed.embed_id] || [],
|
||||||
|
rawData: embed.raw_data || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,6 +735,84 @@ export async function GET({ request }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index message components
|
||||||
|
for (const comp of messageComponents) {
|
||||||
|
if (!componentsByMessage[comp.message_id]) componentsByMessage[comp.message_id] = [];
|
||||||
|
componentsByMessage[comp.message_id].push({
|
||||||
|
id: comp.component_id.toString(),
|
||||||
|
type: comp.component_type,
|
||||||
|
customId: comp.custom_id,
|
||||||
|
label: comp.label,
|
||||||
|
style: comp.style,
|
||||||
|
url: comp.url,
|
||||||
|
disabled: !!comp.disabled,
|
||||||
|
placeholder: comp.placeholder,
|
||||||
|
minValues: comp.min_values,
|
||||||
|
maxValues: comp.max_values,
|
||||||
|
rawData: comp.raw_data || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index poll votes by message_id and answer_id
|
||||||
|
const pollVotesByAnswer = {};
|
||||||
|
for (const vote of pollVotes) {
|
||||||
|
const key = `${vote.message_id}-${vote.answer_id}`;
|
||||||
|
if (!pollVotesByAnswer[key]) {
|
||||||
|
pollVotesByAnswer[key] = [];
|
||||||
|
}
|
||||||
|
// Build avatar URL
|
||||||
|
let avatarUrl = null;
|
||||||
|
if (vote.avatar_url) {
|
||||||
|
if (vote.avatar_url.startsWith('http')) {
|
||||||
|
avatarUrl = vote.avatar_url;
|
||||||
|
} else {
|
||||||
|
avatarUrl = `https://cdn.discordapp.com/avatars/${vote.user_id}/${vote.avatar_url}.png?size=32`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pollVotesByAnswer[key].push({
|
||||||
|
id: vote.user_id.toString(),
|
||||||
|
username: vote.username,
|
||||||
|
displayName: vote.display_name || vote.username,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index poll answers by message_id
|
||||||
|
const pollAnswersByMessage = {};
|
||||||
|
for (const answer of pollAnswers) {
|
||||||
|
if (!pollAnswersByMessage[answer.message_id]) {
|
||||||
|
pollAnswersByMessage[answer.message_id] = [];
|
||||||
|
}
|
||||||
|
const voteKey = `${answer.message_id}-${answer.answer_id}`;
|
||||||
|
pollAnswersByMessage[answer.message_id].push({
|
||||||
|
id: answer.answer_id,
|
||||||
|
text: answer.answer_text,
|
||||||
|
emoji: answer.answer_emoji_id
|
||||||
|
? { id: answer.answer_emoji_id.toString(), name: answer.answer_emoji_name, animated: answer.answer_emoji_animated }
|
||||||
|
: answer.answer_emoji_name ? { name: answer.answer_emoji_name } : null,
|
||||||
|
voteCount: answer.vote_count || 0,
|
||||||
|
voters: pollVotesByAnswer[voteKey] || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index polls by message_id
|
||||||
|
for (const poll of polls) {
|
||||||
|
const answers = pollAnswersByMessage[poll.message_id] || [];
|
||||||
|
pollsByMessage[poll.message_id] = {
|
||||||
|
question: {
|
||||||
|
text: poll.question_text,
|
||||||
|
emoji: poll.question_emoji_id
|
||||||
|
? { id: poll.question_emoji_id.toString(), name: poll.question_emoji_name, animated: poll.question_emoji_animated }
|
||||||
|
: poll.question_emoji_name ? { name: poll.question_emoji_name } : null,
|
||||||
|
},
|
||||||
|
answers,
|
||||||
|
allowMultiselect: poll.allow_multiselect || false,
|
||||||
|
expiry: poll.expiry?.toISOString() || null,
|
||||||
|
isFinalized: poll.is_finalized || false,
|
||||||
|
totalVotes: poll.total_votes || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const ref of referencedMessages) {
|
for (const ref of referencedMessages) {
|
||||||
// Handle avatar URL for referenced message author
|
// Handle avatar URL for referenced message author
|
||||||
const refAvatarSource = ref.author_guild_avatar || ref.author_avatar;
|
const refAvatarSource = ref.author_guild_avatar || ref.author_avatar;
|
||||||
@@ -602,7 +846,8 @@ export async function GET({ request }) {
|
|||||||
let avatarUrl;
|
let avatarUrl;
|
||||||
if (cachedInfo?.cachedAvatarId) {
|
if (cachedInfo?.cachedAvatarId) {
|
||||||
// Use cached avatar from database
|
// Use cached avatar from database
|
||||||
avatarUrl = `${baseUrl}/api/discord/cached-image?id=${cachedInfo.cachedAvatarId}`;
|
const sig = signImageId(cachedInfo.cachedAvatarId);
|
||||||
|
avatarUrl = `${baseUrl}/api/discord/cached-image?id=${cachedInfo.cachedAvatarId}&sig=${sig}`;
|
||||||
} else {
|
} else {
|
||||||
// Handle avatar URL - prefer guild avatar, then user avatar
|
// Handle avatar URL - prefer guild avatar, then user avatar
|
||||||
const avatarSource = msg.author_guild_avatar || msg.author_avatar;
|
const avatarSource = msg.author_guild_avatar || msg.author_avatar;
|
||||||
@@ -635,6 +880,7 @@ export async function GET({ request }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: msg.message_id.toString(),
|
id: msg.message_id.toString(),
|
||||||
|
type: msg.message_type,
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
timestamp: msg.created_at?.toISOString(),
|
timestamp: msg.created_at?.toISOString(),
|
||||||
editedTimestamp: msg.edited_at?.toISOString() || null,
|
editedTimestamp: msg.edited_at?.toISOString() || null,
|
||||||
@@ -651,8 +897,11 @@ export async function GET({ request }) {
|
|||||||
},
|
},
|
||||||
attachments: attachmentsByMessage[msg.message_id] || [],
|
attachments: attachmentsByMessage[msg.message_id] || [],
|
||||||
embeds: embedsByMessage[msg.message_id] || [],
|
embeds: embedsByMessage[msg.message_id] || [],
|
||||||
|
components: componentsByMessage[msg.message_id] || [],
|
||||||
|
rawData: msg.raw_data || null,
|
||||||
stickers: stickersByMessage[msg.message_id] || [],
|
stickers: stickersByMessage[msg.message_id] || [],
|
||||||
reactions: reactionsByMessage[msg.message_id] || [],
|
reactions: reactionsByMessage[msg.message_id] || [],
|
||||||
|
poll: pollsByMessage[msg.message_id] || null,
|
||||||
referencedMessage: msg.reference_message_id
|
referencedMessage: msg.reference_message_id
|
||||||
? referencedById[msg.reference_message_id] || null
|
? referencedById[msg.reference_message_id] || null
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -3,8 +3,32 @@
|
|||||||
*/
|
*/
|
||||||
import sql from '../../../utils/db.js';
|
import sql from '../../../utils/db.js';
|
||||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest,
|
||||||
|
} from '../../../utils/rateLimit.js';
|
||||||
|
import { signImageId } from './cached-image.js';
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
|
// Rate limit check
|
||||||
|
const rateCheck = checkRateLimit(request, {
|
||||||
|
limit: 20,
|
||||||
|
windowMs: 1000,
|
||||||
|
burstLimit: 100,
|
||||||
|
burstWindowMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest(request, 1000);
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
@@ -92,7 +116,8 @@ export async function GET({ request }) {
|
|||||||
const cachedAvatarId = u.cached_guild_avatar_id || u.cached_avatar_id;
|
const cachedAvatarId = u.cached_guild_avatar_id || u.cached_avatar_id;
|
||||||
let avatar;
|
let avatar;
|
||||||
if (cachedAvatarId) {
|
if (cachedAvatarId) {
|
||||||
avatar = `/api/discord/cached-image?id=${cachedAvatarId}`;
|
const sig = signImageId(cachedAvatarId);
|
||||||
|
avatar = `/api/discord/cached-image?id=${cachedAvatarId}&sig=${sig}`;
|
||||||
} else {
|
} else {
|
||||||
avatar = u.guild_avatar || u.avatar_url;
|
avatar = u.guild_avatar || u.avatar_url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,41 @@
|
|||||||
*/
|
*/
|
||||||
import sql from '../../../utils/db.js';
|
import sql from '../../../utils/db.js';
|
||||||
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
import { requireApiAuth } from '../../../utils/apiAuth.js';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
recordRequest,
|
||||||
|
getCookieId,
|
||||||
|
} from '../../../utils/rateLimit.js';
|
||||||
|
|
||||||
|
// Escape LIKE/ILIKE metacharacters to prevent pattern injection
|
||||||
|
function escapeLikePattern(str) {
|
||||||
|
return str.replace(/[%_\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
|
// Rate limit check for authenticated endpoints
|
||||||
|
const rateCheck = checkRateLimit(request, {
|
||||||
|
limit: 10,
|
||||||
|
windowMs: 1000,
|
||||||
|
burstLimit: 50,
|
||||||
|
burstWindowMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: rateCheck.isFlooding ? 'Too many requests - please slow down' : 'Rate limit exceeded'
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest(request, 1000);
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
const { user, error: authError, setCookieHeader } = await requireApiAuth(request);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
// Helper to create responses with optional Set-Cookie header
|
// Helper to create responses with optional Set-Cookie header
|
||||||
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
const createResponse = (data, status = 200) => new Response(JSON.stringify(data), {
|
||||||
@@ -41,7 +71,9 @@ export async function GET({ request }) {
|
|||||||
return createResponse({ error: 'Search query must be at least 2 characters' }, 400);
|
return createResponse({ error: 'Search query must be at least 2 characters' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchPattern = `%${query}%`;
|
// Escape LIKE metacharacters to prevent pattern injection
|
||||||
|
const escapedQuery = escapeLikePattern(query);
|
||||||
|
const searchPattern = `%${escapedQuery}%`;
|
||||||
|
|
||||||
// Search messages by content, author username, or embed content
|
// Search messages by content, author username, or embed content
|
||||||
const messages = await sql`
|
const messages = await sql`
|
||||||
|
|||||||
@@ -12,8 +12,38 @@ import {
|
|||||||
createNonceCookie,
|
createNonceCookie,
|
||||||
} from '../../utils/rateLimit.js';
|
} from '../../utils/rateLimit.js';
|
||||||
|
|
||||||
// Secret key for signing URLs - in production, use an environment variable
|
// Secret key for signing URLs - MUST be set in production
|
||||||
const SIGNING_SECRET = process.env.IMAGE_PROXY_SECRET || 'dev-secret-change-me';
|
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||||
|
if (!SIGNING_SECRET) {
|
||||||
|
console.error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private IP ranges to block (SSRF protection)
|
||||||
|
const PRIVATE_IP_PATTERNS = [
|
||||||
|
/^127\./, // localhost
|
||||||
|
/^10\./, // Class A private
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Class B private
|
||||||
|
/^192\.168\./, // Class C private
|
||||||
|
/^169\.254\./, // Link-local
|
||||||
|
/^0\./, // Current network
|
||||||
|
/^224\./, // Multicast
|
||||||
|
/^255\./, // Broadcast
|
||||||
|
/^localhost$/i,
|
||||||
|
/^\[?::1\]?$/, // IPv6 localhost
|
||||||
|
/^\[?fe80:/i, // IPv6 link-local
|
||||||
|
/^\[?fc00:/i, // IPv6 private
|
||||||
|
/^\[?fd00:/i, // IPv6 private
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPrivateUrl(urlString) {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(hostname));
|
||||||
|
} catch {
|
||||||
|
return true; // Block invalid URLs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Max image size to proxy (25MB - needed for animated GIFs)
|
// Max image size to proxy (25MB - needed for animated GIFs)
|
||||||
const MAX_IMAGE_SIZE = 25 * 1024 * 1024;
|
const MAX_IMAGE_SIZE = 25 * 1024 * 1024;
|
||||||
@@ -136,6 +166,11 @@ export async function GET({ request }) {
|
|||||||
return new Response('Missing signature', { status: 403 });
|
return new Response('Missing signature', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require signing secret to be configured
|
||||||
|
if (!SIGNING_SECRET) {
|
||||||
|
return new Response('Server misconfigured', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the signature
|
// Verify the signature
|
||||||
const isValid = await verifySignature(imageUrl, signature);
|
const isValid = await verifySignature(imageUrl, signature);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
@@ -153,6 +188,11 @@ export async function GET({ request }) {
|
|||||||
return new Response('Invalid URL', { status: 400 });
|
return new Response('Invalid URL', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSRF protection: block private/internal IPs
|
||||||
|
if (isPrivateUrl(imageUrl)) {
|
||||||
|
return new Response('URL not allowed', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Server-side link preview API endpoint
|
* Server-side link preview API endpoint (Node.js / Astro)
|
||||||
* Fetches Open Graph / meta data for URLs to prevent user IP exposure
|
* Uses linkedom for reliable HTML parsing and automatic entity decoding
|
||||||
* Returns signed proxy URLs for images from untrusted domains
|
* Returns signed proxy URLs for images from untrusted domains
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -12,61 +12,29 @@ import {
|
|||||||
createNonceCookie,
|
createNonceCookie,
|
||||||
} from '../../utils/rateLimit.js';
|
} from '../../utils/rateLimit.js';
|
||||||
import { signImageUrl } from './image-proxy.js';
|
import { signImageUrl } from './image-proxy.js';
|
||||||
|
import { parseHTML } from 'linkedom';
|
||||||
|
|
||||||
// Trusted domains that can be loaded client-side (embed-safe providers)
|
// Trusted domains that can be loaded client-side
|
||||||
const TRUSTED_DOMAINS = new Set([
|
const TRUSTED_DOMAINS = new Set([
|
||||||
'youtube.com',
|
'youtube.com', 'www.youtube.com', 'youtu.be', 'img.youtube.com', 'i.ytimg.com',
|
||||||
'www.youtube.com',
|
'instagram.com', 'www.instagram.com',
|
||||||
'youtu.be',
|
'twitter.com', 'x.com', 'www.twitter.com', 'pbs.twimg.com', 'abs.twimg.com',
|
||||||
'img.youtube.com',
|
'twitch.tv', 'www.twitch.tv', 'clips.twitch.tv',
|
||||||
'i.ytimg.com',
|
'spotify.com', 'open.spotify.com',
|
||||||
'instagram.com',
|
'soundcloud.com', 'www.soundcloud.com',
|
||||||
'www.instagram.com',
|
'vimeo.com', 'www.vimeo.com',
|
||||||
'twitter.com',
|
'imgur.com', 'i.imgur.com',
|
||||||
'x.com',
|
'giphy.com', 'media.giphy.com',
|
||||||
'www.twitter.com',
|
'tenor.com', 'media.tenor.com',
|
||||||
'pbs.twimg.com',
|
|
||||||
'abs.twimg.com',
|
|
||||||
'twitch.tv',
|
|
||||||
'www.twitch.tv',
|
|
||||||
'clips.twitch.tv',
|
|
||||||
'spotify.com',
|
|
||||||
'open.spotify.com',
|
|
||||||
'soundcloud.com',
|
|
||||||
'www.soundcloud.com',
|
|
||||||
'vimeo.com',
|
|
||||||
'www.vimeo.com',
|
|
||||||
'imgur.com',
|
|
||||||
'i.imgur.com',
|
|
||||||
'giphy.com',
|
|
||||||
'media.giphy.com',
|
|
||||||
'tenor.com',
|
|
||||||
'media.tenor.com',
|
|
||||||
'gfycat.com',
|
'gfycat.com',
|
||||||
'reddit.com',
|
'reddit.com', 'www.reddit.com', 'v.redd.it', 'i.redd.it', 'preview.redd.it',
|
||||||
'www.reddit.com',
|
'github.com', 'gist.github.com', 'raw.githubusercontent.com', 'avatars.githubusercontent.com', 'user-images.githubusercontent.com',
|
||||||
'v.redd.it',
|
'codepen.io', 'codesandbox.io',
|
||||||
'i.redd.it',
|
'streamable.com', 'medal.tv',
|
||||||
'preview.redd.it',
|
'discord.com', 'cdn.discordapp.com', 'media.discordapp.net',
|
||||||
'github.com',
|
'picsum.photos', 'images.unsplash.com',
|
||||||
'gist.github.com',
|
|
||||||
'raw.githubusercontent.com',
|
|
||||||
'avatars.githubusercontent.com',
|
|
||||||
'user-images.githubusercontent.com',
|
|
||||||
'codepen.io',
|
|
||||||
'codesandbox.io',
|
|
||||||
'streamable.com',
|
|
||||||
'medal.tv',
|
|
||||||
'discord.com',
|
|
||||||
'cdn.discordapp.com',
|
|
||||||
'media.discordapp.net',
|
|
||||||
'picsum.photos',
|
|
||||||
'images.unsplash.com',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL is from a trusted domain
|
|
||||||
*/
|
|
||||||
function isTrustedDomain(url) {
|
function isTrustedDomain(url) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
@@ -76,22 +44,13 @@ function isTrustedDomain(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a safe image URL - either direct (if trusted) or signed proxy URL
|
|
||||||
*/
|
|
||||||
async function getSafeImageUrl(imageUrl) {
|
async function getSafeImageUrl(imageUrl) {
|
||||||
if (!imageUrl) return null;
|
if (!imageUrl) return null;
|
||||||
if (isTrustedDomain(imageUrl)) {
|
if (isTrustedDomain(imageUrl)) return imageUrl;
|
||||||
return imageUrl; // Trusted, return as-is
|
|
||||||
}
|
|
||||||
// Create signed proxy URL
|
|
||||||
const signature = await signImageUrl(imageUrl);
|
const signature = await signImageUrl(imageUrl);
|
||||||
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
|
return `/api/image-proxy?url=${encodeURIComponent(imageUrl)}&sig=${signature}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Open Graph and meta tags from HTML
|
|
||||||
*/
|
|
||||||
function parseMetaTags(html, url) {
|
function parseMetaTags(html, url) {
|
||||||
const meta = {
|
const meta = {
|
||||||
url,
|
url,
|
||||||
@@ -104,101 +63,69 @@ function parseMetaTags(html, url) {
|
|||||||
themeColor: null,
|
themeColor: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to extract content from meta tags
|
const decode = str => str?.replace(/&(#(?:x[0-9a-fA-F]+|\d+)|[a-zA-Z]+);/g,
|
||||||
const getMetaContent = (pattern) => {
|
(_, e) => e[0]==='#' ? String.fromCharCode(e[1]==='x'?parseInt(e.slice(2),16):parseInt(e.slice(1),10))
|
||||||
const match = html.match(pattern);
|
: ({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[e]||_));
|
||||||
return match ? decodeHTMLEntities(match[1]) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Open Graph tags
|
const { document } = parseHTML(html);
|
||||||
meta.title = getMetaContent(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)
|
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i);
|
|
||||||
|
|
||||||
meta.description = getMetaContent(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i);
|
|
||||||
|
|
||||||
meta.image = getMetaContent(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|
// Open Graph / Twitter / fallback
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
|
meta.title =
|
||||||
|
decode(
|
||||||
|
document.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
|
||||||
|
document.querySelector('meta[name="twitter:title"]')?.getAttribute('content') ||
|
||||||
|
document.querySelector('title')?.textContent || null
|
||||||
|
);
|
||||||
|
|
||||||
meta.siteName = getMetaContent(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i)
|
meta.description =
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i);
|
decode(
|
||||||
|
document.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
|
||||||
|
document.querySelector('meta[name="twitter:description"]')?.getAttribute('content') ||
|
||||||
|
document.querySelector('meta[name="description"]')?.getAttribute('content') || null
|
||||||
|
);
|
||||||
|
|
||||||
meta.type = getMetaContent(/<meta[^>]+property=["']og:type["'][^>]+content=["']([^"']+)["']/i)
|
meta.image =
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:type["']/i);
|
decode(
|
||||||
|
document.querySelector('meta[property="og:image"]')?.getAttribute('content') ||
|
||||||
|
document.querySelector('meta[name="twitter:image"]')?.getAttribute('content') || null
|
||||||
|
);
|
||||||
|
|
||||||
meta.video = getMetaContent(/<meta[^>]+property=["']og:video(?::url)?["'][^>]+content=["']([^"']+)["']/i)
|
meta.siteName =
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:video(?::url)?["']/i);
|
decode(
|
||||||
|
document.querySelector('meta[property="og:site_name"]')?.getAttribute('content') ||
|
||||||
|
new URL(url).hostname.replace(/^www\./, '')
|
||||||
|
);
|
||||||
|
|
||||||
// Twitter cards fallback
|
meta.type =
|
||||||
if (!meta.title) {
|
decode(
|
||||||
meta.title = getMetaContent(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i)
|
document.querySelector('meta[property="og:type"]')?.getAttribute('content') || null
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i);
|
);
|
||||||
}
|
|
||||||
if (!meta.description) {
|
|
||||||
meta.description = getMetaContent(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i)
|
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i);
|
|
||||||
}
|
|
||||||
if (!meta.image) {
|
|
||||||
meta.image = getMetaContent(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i)
|
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme color
|
meta.video =
|
||||||
meta.themeColor = getMetaContent(/<meta[^>]+name=["']theme-color["'][^>]+content=["']([^"']+)["']/i)
|
decode(
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']theme-color["']/i);
|
document.querySelector('meta[property="og:video"]')?.getAttribute('content') || null
|
||||||
|
);
|
||||||
|
|
||||||
// Fallback to standard meta tags and title
|
meta.themeColor =
|
||||||
if (!meta.title) {
|
decode(
|
||||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
document.querySelector('meta[name="theme-color"]')?.getAttribute('content') || null
|
||||||
meta.title = titleMatch ? decodeHTMLEntities(titleMatch[1]) : null;
|
);
|
||||||
}
|
|
||||||
if (!meta.description) {
|
|
||||||
meta.description = getMetaContent(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|
|
||||||
|| getMetaContent(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve relative image URLs
|
// Resolve relative image URLs
|
||||||
if (meta.image && !meta.image.startsWith('http')) {
|
if (meta.image && !meta.image.startsWith('http')) {
|
||||||
try {
|
try {
|
||||||
const baseUrl = new URL(url);
|
meta.image = decode(new URL(meta.image, new URL(url).origin).href);
|
||||||
meta.image = new URL(meta.image, baseUrl.origin).href;
|
|
||||||
} catch {
|
} catch {
|
||||||
meta.image = null;
|
meta.image = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get site name from domain if not found
|
|
||||||
if (!meta.siteName) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
meta.siteName = parsed.hostname.replace(/^www\./, '');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode HTML entities
|
|
||||||
*/
|
|
||||||
function decodeHTMLEntities(text) {
|
|
||||||
if (!text) return text;
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(///g, '/')
|
|
||||||
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
|
|
||||||
.replace(/&#x([a-fA-F0-9]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET({ request }) {
|
export async function GET({ request }) {
|
||||||
// Rate limit check
|
// Rate limit
|
||||||
const rateCheck = checkRateLimit(request, {
|
const rateCheck = checkRateLimit(request, {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
windowMs: 1000,
|
windowMs: 1000,
|
||||||
@@ -208,25 +135,18 @@ export async function GET({ request }) {
|
|||||||
|
|
||||||
let cookieId = getCookieId(request);
|
let cookieId = getCookieId(request);
|
||||||
const hadCookie = !!cookieId;
|
const hadCookie = !!cookieId;
|
||||||
if (!cookieId) {
|
if (!cookieId) cookieId = generateNonce();
|
||||||
cookieId = generateNonce();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rateCheck.allowed) {
|
if (!rateCheck.allowed) {
|
||||||
const errorMsg = rateCheck.isFlooding
|
const errorMsg = rateCheck.isFlooding
|
||||||
? { error: 'Too many requests - please slow down' }
|
? { error: 'Too many requests - please slow down' }
|
||||||
: { error: 'Rate limit exceeded' };
|
: { error: 'Rate limit exceeded' };
|
||||||
const response = new Response(JSON.stringify(errorMsg), {
|
const resp = new Response(JSON.stringify(errorMsg), {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'Retry-After': '1' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Retry-After': '1',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!hadCookie) {
|
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
return resp;
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recordRequest(request, 1000);
|
recordRequest(request, 1000);
|
||||||
@@ -241,13 +161,11 @@ export async function GET({ request }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate URL format
|
// Validate URL
|
||||||
let parsedUrl;
|
let parsedUrl;
|
||||||
try {
|
try {
|
||||||
parsedUrl = new URL(targetUrl);
|
parsedUrl = new URL(targetUrl);
|
||||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) throw new Error();
|
||||||
throw new Error('Invalid protocol');
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(JSON.stringify({ error: 'Invalid URL' }), {
|
return new Response(JSON.stringify({ error: 'Invalid URL' }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -255,7 +173,6 @@ export async function GET({ request }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a trusted domain (client can fetch directly)
|
|
||||||
const trusted = isTrustedDomain(targetUrl);
|
const trusted = isTrustedDomain(targetUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -287,70 +204,43 @@ export async function GET({ request }) {
|
|||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
// Handle image URLs directly - return safe (possibly proxied) URL
|
// Handle direct image
|
||||||
if (contentType.startsWith('image/')) {
|
if (contentType.startsWith('image/')) {
|
||||||
const safeImageUrl = await getSafeImageUrl(targetUrl);
|
const safeImageUrl = await getSafeImageUrl(targetUrl);
|
||||||
const result = {
|
const result = { url: targetUrl, type: 'image', image: safeImageUrl, trusted };
|
||||||
url: targetUrl,
|
|
||||||
type: 'image',
|
|
||||||
image: safeImageUrl,
|
|
||||||
trusted,
|
|
||||||
};
|
|
||||||
const resp = new Response(JSON.stringify(result), {
|
const resp = new Response(JSON.stringify(result), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'public, max-age=3600',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!hadCookie) {
|
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
|
||||||
}
|
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle video URLs directly (no proxy for video - too large)
|
// Handle direct video
|
||||||
if (contentType.startsWith('video/')) {
|
if (contentType.startsWith('video/')) {
|
||||||
// Only allow trusted video sources
|
|
||||||
if (!trusted) {
|
if (!trusted) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({ error: 'Untrusted video source' }), {
|
||||||
error: 'Untrusted video source',
|
|
||||||
}), {
|
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = {
|
const result = { url: targetUrl, type: 'video', video: targetUrl, trusted };
|
||||||
url: targetUrl,
|
|
||||||
type: 'video',
|
|
||||||
video: targetUrl,
|
|
||||||
trusted,
|
|
||||||
};
|
|
||||||
const resp = new Response(JSON.stringify(result), {
|
const resp = new Response(JSON.stringify(result), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'public, max-age=3600',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!hadCookie) {
|
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
|
||||||
}
|
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only parse HTML
|
|
||||||
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({ error: 'URL is not an HTML page', contentType }), {
|
||||||
error: 'URL is not an HTML page',
|
|
||||||
contentType
|
|
||||||
}), {
|
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read only the first 50KB to get meta tags (they're usually in <head>)
|
// Read first 50KB
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
let html = '';
|
let html = '';
|
||||||
let bytesRead = 0;
|
let bytesRead = 0;
|
||||||
@@ -361,7 +251,6 @@ export async function GET({ request }) {
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
html += new TextDecoder().decode(value);
|
html += new TextDecoder().decode(value);
|
||||||
bytesRead += value.length;
|
bytesRead += value.length;
|
||||||
// Stop early if we've passed </head>
|
|
||||||
if (html.includes('</head>')) break;
|
if (html.includes('</head>')) break;
|
||||||
}
|
}
|
||||||
reader.cancel();
|
reader.cancel();
|
||||||
@@ -369,29 +258,20 @@ export async function GET({ request }) {
|
|||||||
const meta = parseMetaTags(html, targetUrl);
|
const meta = parseMetaTags(html, targetUrl);
|
||||||
meta.trusted = trusted;
|
meta.trusted = trusted;
|
||||||
|
|
||||||
// Convert image URL to safe URL (proxy if untrusted)
|
// Convert image to safe URL
|
||||||
if (meta.image) {
|
if (meta.image) meta.image = await getSafeImageUrl(meta.image);
|
||||||
meta.image = await getSafeImageUrl(meta.image);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = new Response(JSON.stringify(meta), {
|
const resp = new Response(JSON.stringify(meta), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Cache-Control': 'public, max-age=3600',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!hadCookie) {
|
if (!hadCookie) resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
resp.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
|
||||||
}
|
|
||||||
return resp;
|
return resp;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[link-preview] Error fetching URL:', err.message);
|
console.error('[link-preview] Error fetching URL:', err.message);
|
||||||
return new Response(JSON.stringify({
|
// Don't expose internal error details to client
|
||||||
error: 'Failed to fetch preview',
|
return new Response(JSON.stringify({ error: 'Failed to fetch preview' }), {
|
||||||
message: err.message
|
|
||||||
}), {
|
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,19 @@
|
|||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import Root from "../components/AppLayout.jsx";
|
import Root from "../components/AppLayout.jsx";
|
||||||
import "@styles/DiscordLogs.css";
|
import "@styles/DiscordLogs.css";
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
|
||||||
|
|
||||||
const user = await requireAuthHook(Astro);
|
// Auth + role check handled by middleware
|
||||||
|
// Middleware redirects to /login if not authenticated or lacks 'discord' role
|
||||||
|
const user = Astro.locals.user as any;
|
||||||
|
|
||||||
if (!user) {
|
// Prevent browser caching of authenticated pages
|
||||||
return Astro.redirect('/login');
|
Astro.response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
||||||
}
|
Astro.response.headers.set('Pragma', 'no-cache');
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base title="Discord Archive" description="Archived Discord channel logs">
|
<Base title="Discord Archive" description="Archived Discord channel logs">
|
||||||
<section class="discord-logs-section">
|
<section class="page-section discord-logs-section">
|
||||||
<div class="discord-logs-page">
|
<Root child="DiscordLogs" client:only="react" />
|
||||||
<Root child="DiscordLogs" client:only="react" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
@@ -24,9 +23,4 @@ if (!user) {
|
|||||||
body:has(.discord-logs-section) main.page-enter {
|
body:has(.discord-logs-section) main.page-enter {
|
||||||
max-width: 1400px !important;
|
max-width: 1400px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discord-logs-page {
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,21 +16,12 @@ const whitelabel = WHITELABELS[host] ?? (detected ? WHITELABELS[detected.host] :
|
|||||||
|
|
||||||
<Base>
|
<Base>
|
||||||
{whitelabel ? (
|
{whitelabel ? (
|
||||||
<section>
|
<section class="page-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="ReqForm" client:only="react" />
|
||||||
<Root child="ReqForm" client:only="react">
|
|
||||||
</Root>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section>
|
<section class="page-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="LyricSearch" client:only="react" />
|
||||||
<Root
|
|
||||||
child="LyricSearch"
|
|
||||||
client:only="react"
|
|
||||||
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
---
|
---
|
||||||
import Base from "@/layouts/Base.astro";
|
import Base from "@/layouts/Base.astro";
|
||||||
import Root from "@/components/AppLayout.jsx";
|
import Root from "@/components/AppLayout.jsx";
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
|
||||||
|
|
||||||
const user = await requireAuthHook(Astro);
|
// Auth + role check handled by middleware
|
||||||
|
// Middleware redirects to /login if not authenticated or lacks 'lighting' role
|
||||||
if (!user || !user.roles.includes('lighting')) {
|
const user = Astro.locals.user as any;
|
||||||
return Astro.redirect('/login');
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base>
|
<Base>
|
||||||
<section>
|
<section class="page-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="Lighting" user?={user} client:only="react" />
|
||||||
<Root child="Lighting" user?={user} client:only="react" />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ const isLoggedIn = Boolean(user);
|
|||||||
|
|
||||||
---
|
---
|
||||||
<Base>
|
<Base>
|
||||||
<section>
|
<section class="page-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" />
|
||||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react">
|
|
||||||
</Root>
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
@@ -4,10 +4,8 @@ import Root from "../components/AppLayout.jsx";
|
|||||||
import "@styles/MemeGrid.css";
|
import "@styles/MemeGrid.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base>
|
<Base hideFooter>
|
||||||
<section>
|
<section class="page-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="Memes" client:only="react" />
|
||||||
<Root child="Memes" client:only="react">
|
|
||||||
</Root>
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
---
|
---
|
||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import Root from "../components/AppLayout.jsx";
|
import Root from "../components/AppLayout.jsx";
|
||||||
// The Base layout exposes runtime subsite state — no per-page detection needed
|
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
// Auth handled by middleware - user available in Astro.locals.user
|
||||||
const user = await requireAuthHook(Astro);
|
const user = Astro.locals.user as any;
|
||||||
---
|
---
|
||||||
<Base>
|
<Base>
|
||||||
<section>
|
<section class="page-section">
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<Root child="Player" user={user} client:only="react" />
|
||||||
<Root child="Player" user={user} client:only="react">
|
|
||||||
</Root>
|
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { API_URL } from "@/config";
|
|||||||
|
|
||||||
// Track in-flight refresh to avoid duplicate requests
|
// Track in-flight refresh to avoid duplicate requests
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
|
let lastRefreshTime = 0;
|
||||||
|
const REFRESH_COOLDOWN = 2000; // 2 second cooldown between refreshes
|
||||||
|
|
||||||
// Auth fetch wrapper
|
// Auth fetch wrapper
|
||||||
export const authFetch = async (url, options = {}, retry = true) => {
|
export const authFetch = async (url, options = {}, retry = true) => {
|
||||||
@@ -13,19 +15,9 @@ export const authFetch = async (url, options = {}, retry = true) => {
|
|||||||
if (res.status === 401 && retry) {
|
if (res.status === 401 && retry) {
|
||||||
// attempt refresh (non-blocking if already in progress)
|
// attempt refresh (non-blocking if already in progress)
|
||||||
try {
|
try {
|
||||||
// Reuse existing refresh promise if one is in flight
|
const refreshSuccess = await doRefresh();
|
||||||
if (!refreshPromise) {
|
|
||||||
refreshPromise = fetch(`${API_URL}/auth/refresh`, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
}).finally(() => {
|
|
||||||
refreshPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshRes = await refreshPromise;
|
if (!refreshSuccess) throw new Error("Refresh failed");
|
||||||
|
|
||||||
if (!refreshRes.ok) throw new Error("Refresh failed");
|
|
||||||
|
|
||||||
// Retry original request once after refresh
|
// Retry original request once after refresh
|
||||||
return authFetch(url, options, false);
|
return authFetch(url, options, false);
|
||||||
@@ -38,6 +30,44 @@ export const authFetch = async (url, options = {}, retry = true) => {
|
|||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Centralized refresh function that handles deduplication properly
|
||||||
|
async function doRefresh() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If a refresh just succeeded recently, assume we're good
|
||||||
|
if (now - lastRefreshTime < REFRESH_COOLDOWN && !refreshPromise) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse existing refresh promise if one is in flight
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refreshRes.ok) {
|
||||||
|
lastRefreshTime = Date.now();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Refresh request failed:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Clear the promise after it resolves
|
||||||
|
refreshPromise.finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh token function (HttpOnly cookie flow)
|
// Refresh token function (HttpOnly cookie flow)
|
||||||
export async function refreshAccessToken(cookieHeader) {
|
export async function refreshAccessToken(cookieHeader) {
|
||||||
try {
|
try {
|
||||||
@@ -79,18 +109,10 @@ export async function ensureAuth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
// Try to refresh the token via our external auth API
|
// Try to refresh the token using our centralized refresh handler
|
||||||
if (!refreshPromise) {
|
const refreshSuccess = await doRefresh();
|
||||||
refreshPromise = fetch(`${API_URL}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
}).finally(() => {
|
|
||||||
refreshPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshRes = await refreshPromise;
|
if (refreshSuccess) {
|
||||||
if (refreshRes.ok) {
|
|
||||||
// Retry the auth check after refresh
|
// Retry the auth check after refresh
|
||||||
const retryRes = await fetch('/api/discord/channels', {
|
const retryRes = await fetch('/api/discord/channels', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -3,14 +3,30 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
const secretFilePath = path.join(
|
// JWT keys location - can be configured via environment variable
|
||||||
|
// In production, prefer using a secret management service (Vault, AWS Secrets Manager, etc.)
|
||||||
|
const secretFilePath = import.meta.env.JWT_KEYS_PATH || path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
'.config',
|
'.config',
|
||||||
'api_jwt_keys.json'
|
'api_jwt_keys.json'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Warn if using default location in production
|
||||||
|
if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) {
|
||||||
|
console.warn(
|
||||||
|
'[SECURITY WARNING] JWT_KEYS_PATH not set. Using default location ~/.config/api_jwt_keys.json. ' +
|
||||||
|
'Consider using a secret management service in production.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Load and parse keys JSON once at startup
|
// Load and parse keys JSON once at startup
|
||||||
const keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
let keyFileData;
|
||||||
|
try {
|
||||||
|
keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, err.message);
|
||||||
|
throw new Error('JWT keys file not found or invalid. Set JWT_KEYS_PATH environment variable.');
|
||||||
|
}
|
||||||
|
|
||||||
export function verifyToken(token) {
|
export function verifyToken(token) {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -14,6 +14,40 @@ const floodProtectionMap = new Map();
|
|||||||
const CLEANUP_INTERVAL = 60_000;
|
const CLEANUP_INTERVAL = 60_000;
|
||||||
let lastCleanup = Date.now();
|
let lastCleanup = Date.now();
|
||||||
|
|
||||||
|
// Trusted proxy configuration - only trust proxy headers from known sources
|
||||||
|
// Set TRUSTED_PROXY_IPS env var to comma-separated list of IPs/CIDRs
|
||||||
|
// If behind Cloudflare, Vercel, or similar, their proxy IPs are implicitly trusted
|
||||||
|
const TRUSTED_PROXIES = new Set(
|
||||||
|
(import.meta.env.TRUSTED_PROXY_IPS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(ip => ip.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Common cloud provider proxy indicators
|
||||||
|
const VERCEL_INDICATOR = import.meta.env.VERCEL === '1';
|
||||||
|
const CLOUDFLARE_INDICATOR = typeof import.meta.env.CF_PAGES !== 'undefined' ||
|
||||||
|
typeof import.meta.env.CF_PAGES_URL !== 'undefined';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request is from a trusted proxy.
|
||||||
|
* In production behind Vercel/Cloudflare, proxy headers are trustworthy.
|
||||||
|
*/
|
||||||
|
function isTrustedProxy(request) {
|
||||||
|
// If running on Vercel or Cloudflare, trust their headers
|
||||||
|
if (VERCEL_INDICATOR || CLOUDFLARE_INDICATOR) return true;
|
||||||
|
|
||||||
|
// If specific trusted proxies are configured, check them
|
||||||
|
if (TRUSTED_PROXIES.size > 0) {
|
||||||
|
// In a real deployment, you'd check the connecting IP against trusted IPs
|
||||||
|
// For now, if TRUSTED_PROXY_IPS is set, we trust the headers
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: don't trust proxy headers (direct connection)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupStaleEntries() {
|
function cleanupStaleEntries() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||||
@@ -32,29 +66,38 @@ function cleanupStaleEntries() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract client IP from request headers with proxy support.
|
* Extract client IP from request headers with proxy support.
|
||||||
* Falls back through common proxy headers.
|
* Only trusts proxy headers when behind a known/configured proxy.
|
||||||
*/
|
*/
|
||||||
export function getClientIp(request) {
|
export function getClientIp(request) {
|
||||||
const headers = request.headers;
|
const headers = request.headers;
|
||||||
|
|
||||||
// Try standard proxy headers in order of preference
|
// Only trust proxy headers if we're behind a trusted proxy
|
||||||
const xForwardedFor = headers.get('x-forwarded-for');
|
if (isTrustedProxy(request)) {
|
||||||
if (xForwardedFor) {
|
// Cloudflare's header is most reliable when using Cloudflare
|
||||||
// Take first IP (original client), trim whitespace
|
const cfConnectingIp = headers.get('cf-connecting-ip');
|
||||||
const ip = xForwardedFor.split(',')[0].trim();
|
if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp);
|
||||||
if (ip && isValidIp(ip)) return normalizeIp(ip);
|
|
||||||
|
// Vercel/standard proxy header
|
||||||
|
const xForwardedFor = headers.get('x-forwarded-for');
|
||||||
|
if (xForwardedFor) {
|
||||||
|
// Take first IP (original client), trim whitespace
|
||||||
|
const ip = xForwardedFor.split(',')[0].trim();
|
||||||
|
if (ip && isValidIp(ip)) return normalizeIp(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xRealIp = headers.get('x-real-ip');
|
||||||
|
if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp);
|
||||||
|
|
||||||
|
const trueClientIp = headers.get('true-client-ip');
|
||||||
|
if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xRealIp = headers.get('x-real-ip');
|
// Fallback - in production this typically means misconfiguration
|
||||||
if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp);
|
// Log this case in development to catch configuration issues
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[RateLimit] Could not determine client IP - proxy headers not trusted');
|
||||||
|
}
|
||||||
|
|
||||||
const cfConnectingIp = headers.get('cf-connecting-ip');
|
|
||||||
if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp);
|
|
||||||
|
|
||||||
const trueClientIp = headers.get('true-client-ip');
|
|
||||||
if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp);
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user