From ce3dfd0cdd49634afea7fe07a5d1632844c09b2e Mon Sep 17 00:00:00 2001 From: "cool.gitter.choco" Date: Sat, 15 Mar 2025 20:39:29 -0600 Subject: [PATCH] frontend prettier --- static/css/album/album.css | 113 +++-- static/css/artist/artist.css | 117 +++-- static/css/config/config.css | 523 +++++++++++++++----- static/css/main/base.css | 457 ++++++++++++++++++ static/css/main/icons.css | 40 ++ static/css/main/main.css | 318 +++++++----- static/css/playlist/playlist.css | 67 +++ static/css/queue/queue.css | 251 ++++++++-- static/css/track/track.css | 100 +++- static/images/album.svg | 5 + static/images/arrow-left.svg | 4 + static/images/music.svg | 5 + static/images/queue-empty.svg | 20 + static/images/search.svg | 4 + static/js/main.js | 797 +++++++++++++++---------------- static/js/queue.js | 266 +++++++++-- templates/album.html | 60 ++- templates/artist.html | 59 ++- templates/config.html | 260 +++++----- templates/main.html | 80 ++-- templates/playlist.html | 67 ++- templates/track.html | 69 ++- 22 files changed, 2635 insertions(+), 1047 deletions(-) create mode 100644 static/css/main/base.css create mode 100644 static/images/album.svg create mode 100644 static/images/arrow-left.svg create mode 100644 static/images/music.svg create mode 100644 static/images/queue-empty.svg create mode 100644 static/images/search.svg diff --git a/static/css/album/album.css b/static/css/album/album.css index 6f25986..4f389e3 100644 --- a/static/css/album/album.css +++ b/static/css/album/album.css @@ -95,54 +95,52 @@ body { /* Individual Track Styling */ .track { - display: flex; + display: grid; + grid-template-columns: 40px 1fr auto auto; align-items: center; - padding: 1rem; - background: #181818; - border-radius: 8px; - transition: background 0.3s ease; - flex-wrap: wrap; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + background-color: var(--color-surface); + margin-bottom: 0.5rem; + transition: background-color 0.2s ease; } .track:hover { - background: #2a2a2a; + background-color: var(--color-surface-hover); } .track-number { - width: 30px; - font-size: 1rem; - font-weight: 500; - text-align: center; - margin-right: 1rem; - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + color: var(--color-text-secondary); + width: 24px; } .track-info { + padding: 0 1rem; flex: 1; - display: flex; - flex-direction: column; min-width: 0; - align-items: flex-start; } .track-name { - font-size: 1rem; - font-weight: bold; - word-wrap: break-word; + font-weight: 500; + margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .track-artist { font-size: 0.9rem; - color: #b3b3b3; + color: var(--color-text-secondary); } .track-duration { - width: 60px; - text-align: right; + color: var(--color-text-tertiary); font-size: 0.9rem; - color: #b3b3b3; - margin-left: 1rem; - flex-shrink: 0; + margin-right: 1rem; } /* Loading and Error States */ @@ -296,15 +294,12 @@ body { } .track { - flex-direction: column; - align-items: flex-start; + grid-template-columns: 30px 1fr auto auto; + padding: 0.6rem 0.8rem; } - + .track-duration { - margin-left: 0; - margin-top: 0.5rem; - width: 100%; - text-align: left; + margin-right: 0.5rem; } } @@ -336,23 +331,19 @@ body { } .track { - padding: 0.8rem; - flex-direction: column; - align-items: center; - text-align: center; + grid-template-columns: 30px 1fr auto; } - - .track-number { - font-size: 0.9rem; - margin-right: 0; - margin-bottom: 0.5rem; + + .track-info { + padding: 0 0.5rem; } - - .track-duration { - margin-left: 0; - margin-top: 0.5rem; - width: 100%; - text-align: center; + + .track-name, .track-artist { + max-width: 200px; + } + + .section-title { + font-size: 1.25rem; } /* Ensure the actions container lays out buttons properly */ @@ -385,3 +376,31 @@ a:focus { .download-btn--circle::before { content: none; } + +/* Album page specific styles */ + +/* Add some context styles for the album copyright */ +.album-copyright { + font-size: 0.85rem; + color: var(--color-text-tertiary); + margin-top: 0.5rem; + font-style: italic; +} + +/* Section title styling */ +.section-title { + font-size: 1.5rem; + margin-bottom: 1rem; + position: relative; + padding-bottom: 0.5rem; +} + +.section-title:after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 50px; + height: 2px; + background-color: var(--color-primary); +} diff --git a/static/css/artist/artist.css b/static/css/artist/artist.css index 459b808..97887e7 100644 --- a/static/css/artist/artist.css +++ b/static/css/artist/artist.css @@ -72,72 +72,94 @@ body { padding-bottom: 0.5rem; } -/* Album Groups */ +/* Album groups layout */ +.album-groups { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Album group section */ .album-group { - margin-bottom: 2rem; + margin-bottom: 1.5rem; } .album-group-header { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; margin-bottom: 1rem; - padding: 0.5rem 0; - border-bottom: 1px solid #2a2a2a; } .album-group-header h3 { - font-size: 1.5rem; - margin: 0; - text-transform: capitalize; + font-size: 1.3rem; + position: relative; + padding-bottom: 0.5rem; } +.album-group-header h3::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 40px; + height: 2px; + background-color: var(--color-primary); +} + +.group-download-btn { + padding: 0.4rem 0.8rem; + font-size: 0.9rem; +} + +/* Albums grid layout */ .albums-list { - display: flex; - flex-direction: column; - gap: 1rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1.5rem; + margin-top: 1rem; } -/* Unified Album Card (Desktop & Mobile) */ +/* Album card styling */ .album-card { - background: #181818; - border-radius: 8px; + background-color: var(--color-surface); + border-radius: var(--radius-md); overflow: hidden; - box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: transform 0.2s ease, box-shadow 0.2s ease; - display: flex; - align-items: center; - padding: 0.5rem; + position: relative; + box-shadow: var(--shadow-sm); } .album-card:hover { transform: translateY(-5px); - box-shadow: 0 4px 8px rgba(0,0,0,0.3); + box-shadow: var(--shadow-md); } -/* Album Cover Image */ .album-cover { - width: 80px; - height: 80px; + width: 100%; + aspect-ratio: 1/1; object-fit: cover; - border-radius: 4px; - margin-right: 1rem; + transition: opacity 0.2s ease; } -/* Album Info */ .album-info { - flex: 1; + padding: 0.75rem; } .album-title { - font-size: 1rem; - font-weight: bold; + font-weight: 600; margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .album-artist { font-size: 0.9rem; - color: #b3b3b3; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* Track Card (for Albums or Songs) */ @@ -331,6 +353,20 @@ body { width: 100%; text-align: center; } + + .albums-list { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; + } + + .album-group-header { + flex-direction: column; + align-items: flex-start; + } + + .group-download-btn { + margin-top: 0.5rem; + } } /* Small Devices (Mobile Phones) */ @@ -368,36 +404,21 @@ body { text-align: center; } - /* Mobile Album Grid Styles Inspired by Spotify */ .albums-list { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; - } - - /* Adjust album card for mobile grid */ - .album-card { - flex-direction: column; - padding: 0; - } - - .album-cover { - width: 100%; - aspect-ratio: 1 / 1; - margin: 0; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.75rem; } .album-info { padding: 0.5rem; - text-align: center; } .album-title { - font-size: 1rem; + font-size: 0.9rem; } .album-artist { - font-size: 0.9rem; + font-size: 0.8rem; } } diff --git a/static/css/config/config.css b/static/css/config/config.css index afc5d17..5e1d15d 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -32,39 +32,223 @@ body { transition: all 0.3s ease; } -/* Modern Back Button */ -.back-button { - background: #1db954; - color: #ffffff; - padding: 0.8rem 1.5rem; - border-radius: 25px; - text-decoration: none; - font-weight: 500; - transition: background 0.3s ease, transform 0.2s ease; +/* Back button as floating icon - keep this for our floating button */ +.back-button.floating-icon { + position: fixed; + width: 56px; + height: 56px; + bottom: 20px; + left: 20px; + background-color: var(--color-primary); + border-radius: 50%; + box-shadow: var(--shadow-lg); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, background-color 0.2s ease; + text-decoration: none !important; } -.back-button:hover { - background: #1ed760; +.back-button.floating-icon:hover { + background-color: var(--color-primary-hover); + transform: scale(1.05); +} + +.back-button.floating-icon img { + width: 24px; + height: 24px; + filter: brightness(0) invert(1); + margin: 0; +} + +/* Queue button as floating icon */ +.queue-icon.floating-icon { + position: fixed; + width: 56px; + height: 56px; + bottom: 20px; + right: 20px; + background-color: var(--color-primary); + border-radius: 50%; + box-shadow: var(--shadow-lg); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, background-color 0.2s ease; + text-decoration: none !important; +} + +.queue-icon.floating-icon:hover { + background-color: var(--color-primary-hover); + transform: scale(1.05); +} + +.queue-icon.floating-icon img { + width: 24px; + height: 24px; + filter: brightness(0) invert(1); + margin: 0; +} + +/* Queue Icon Active State */ +.queue-icon.queue-icon-active { + background-color: #d13838 !important; + transition: background-color 0.3s ease; +} + +.queue-icon.queue-icon-active:hover { + background-color: #e04c4c !important; +} + +.queue-icon .queue-x { + font-size: 28px; + font-weight: bold; + color: white; + line-height: 24px; + display: inline-block; transform: translateY(-2px); } -/* Queue Icon in Header */ -#queueIcon { - background: none; +/* Queue Sidebar for Config Page */ +#downloadQueue { + position: fixed; + top: 0; + right: -350px; + width: 350px; + height: 100vh; + background: #181818; + padding: 20px; + transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1001; + box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; +} + +#downloadQueue.active { + right: 0; +} + +/* Header inside the queue sidebar */ +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 15px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 20px; +} + +.sidebar-header h2 { + font-size: 1.25rem; + font-weight: 600; + color: #fff; + margin: 0; +} + +.header-actions { + display: flex; + gap: 10px; + align-items: center; +} + +/* Cancel all button styling */ +#cancelAllBtn { + background: #8b0000; border: none; + color: #fff; + padding: 8px 12px; + border-radius: 4px; cursor: pointer; - padding: 4px; + transition: background 0.3s ease, transform 0.2s ease; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } -#queueIcon img { - width: 24px; - height: 24px; - filter: invert(1); - transition: opacity 0.3s ease; +#cancelAllBtn:hover { + background: #a30000; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); } -#queueIcon:hover img { - opacity: 0.8; +#cancelAllBtn:active { + transform: scale(0.98); +} + +#cancelAllBtn .skull-icon { + width: 16px; + height: 16px; + margin-right: 8px; + vertical-align: middle; + filter: brightness(0) invert(1); + transition: transform 0.3s ease; +} + +#cancelAllBtn:hover .skull-icon { + transform: rotate(-10deg) scale(1.2); + animation: skullShake 0.5s infinite alternate; +} + +@keyframes skullShake { + 0% { transform: rotate(-5deg); } + 100% { transform: rotate(5deg); } +} + +/* Empty queue state */ +.queue-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 200px; + color: #b3b3b3; + text-align: center; + padding: 20px; +} + +.queue-empty img { + width: 60px; + height: 60px; + margin-bottom: 15px; + opacity: 0.6; +} + +.queue-empty p { + font-size: 14px; + line-height: 1.5; +} + +/* Mobile responsiveness for queue in Config page */ +@media (max-width: 600px) { + #downloadQueue { + width: 100%; + right: -100%; + padding: 15px; + } + + #downloadQueue.active { + right: 0; + } + + .sidebar-header { + padding-bottom: 12px; + margin-bottom: 15px; + } + + .sidebar-header h2 { + font-size: 1.1rem; + } + + #cancelAllBtn { + padding: 6px 10px; + font-size: 12px; + } } /* Account Configuration Section */ @@ -81,6 +265,25 @@ body { transform: translateY(-2px); } +/* Section Titles */ +.section-title { + font-size: 1.5rem; + margin-bottom: 1.5rem; + position: relative; + padding-bottom: 0.75rem; + color: var(--color-text-primary); +} + +.section-title::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 50px; + height: 2px; + background-color: var(--color-primary); +} + .config-item { margin-bottom: 1.5rem; position: relative; @@ -94,10 +297,7 @@ body { } /* Enhanced Dropdown Styling */ -#spotifyAccountSelect, -#deezerAccountSelect, -#spotifyQualitySelect, -#deezerQualitySelect { +.form-select { background: #2a2a2a; color: #ffffff; border: 1px solid #404040; @@ -115,33 +315,24 @@ body { transition: all 0.3s ease; } -#spotifyAccountSelect:focus, -#deezerAccountSelect:focus, -#spotifyQualitySelect:focus, -#deezerQualitySelect:focus { +.form-select:focus { outline: none; border-color: #1db954; box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); } -#spotifyAccountSelect option, -#deezerAccountSelect option, -#spotifyQualitySelect option, -#deezerQualitySelect option { +.form-select option { background: #181818; color: #ffffff; padding: 0.8rem; } -#spotifyAccountSelect option:hover, -#deezerAccountSelect option:hover, -#spotifyQualitySelect option:hover, -#deezerQualitySelect option:hover { +.form-select option:hover { background: #1db954; } /* New Input Styling for Custom Format Fields */ -.config-item input[type="text"] { +.form-input { width: 100%; padding: 0.8rem; background: #2a2a2a; @@ -152,22 +343,29 @@ body { transition: border-color 0.3s ease; } -.config-item input[type="text"]:focus { +.form-input:focus { outline: none; border-color: #1db954; + box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); } /* Improved Toggle Switches */ .switch { position: relative; display: inline-block; - width: 40px; - height: 20px; - margin-left: 1rem; + width: 48px; + height: 24px; + margin-top: 0.5rem; vertical-align: middle; overflow: hidden; } +.switch input { + opacity: 0; + width: 0; + height: 0; +} + .slider { position: absolute; cursor: pointer; @@ -177,16 +375,16 @@ body { bottom: 0; background-color: #666; transition: 0.4s; - border-radius: 20px; + border-radius: 24px; } .slider:before { position: absolute; content: ""; - height: 16px; - width: 16px; - left: 2px; - bottom: 2px; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; background-color: #ffffff; transition: 0.4s; border-radius: 50%; @@ -198,7 +396,7 @@ input:checked + .slider { } input:checked + .slider:before { - transform: translateX(20px); + transform: translateX(24px); } /* Setting description */ @@ -209,6 +407,11 @@ input:checked + .slider:before { line-height: 1.4; } +/* Accounts section layout */ +.accounts-section { + margin-top: 2rem; +} + /* Service Tabs */ .service-tabs { display: flex; @@ -225,6 +428,8 @@ input:checked + .slider:before { cursor: pointer; font-size: 0.95rem; transition: background 0.3s ease, transform 0.2s ease; + flex: 1; + text-align: center; } .tab-button.active { @@ -232,6 +437,10 @@ input:checked + .slider:before { transform: translateY(-2px); } +.tab-button:hover:not(.active) { + background: #333333; +} + /* No Credentials Message */ .no-credentials { padding: 1.5rem; @@ -245,21 +454,32 @@ input:checked + .slider:before { /* Credentials List */ .credentials-list { margin-bottom: 2rem; + padding: 1.5rem; + background: #181818; + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); } .credential-item { display: flex; justify-content: space-between; align-items: center; - padding: 1rem; + padding: 1.25rem; background: #2a2a2a; border-radius: 8px; margin-bottom: 0.75rem; - transition: background 0.3s ease; + transition: all 0.3s ease; + border-left: 3px solid var(--color-primary); } .credential-item:hover { - background: #3a3a3a; + background: #333333; + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.credential-item:last-child { + margin-bottom: 0; } /* New styling for credential info and actions */ @@ -267,19 +487,40 @@ input:checked + .slider:before { display: flex; flex-direction: column; gap: 0.25rem; + flex: 1; + padding-right: 1rem; } .credential-name { font-weight: 600; - font-size: 1rem; + font-size: 1.1rem; + color: var(--color-text-primary); + margin-bottom: 0.5rem; +} + +.credential-type { + display: inline-block; + padding: 0.25rem 0.5rem; + background-color: rgba(29, 185, 84, 0.1); + border-radius: 8px; + font-size: 0.8rem; + color: var(--color-primary); + margin-bottom: 0.5rem; +} + +.credential-details { + font-size: 0.9rem; + color: var(--color-text-secondary); + margin-top: 0.25rem; } .search-credentials-status { - font-size: 0.8rem; - padding: 0.2rem 0.5rem; + font-size: 0.85rem; + padding: 0.25rem 0.5rem; border-radius: 12px; display: inline-block; width: fit-content; + margin-top: 0.5rem; } .search-credentials-status.has-api { @@ -294,37 +535,44 @@ input:checked + .slider:before { .credential-actions { display: flex; - gap: 0.5rem; + gap: 0.75rem; } .credential-actions button { - padding: 0.5rem 0.8rem; + background-color: #222222; border: none; - border-radius: 6px; cursor: pointer; - font-size: 0.8rem; - transition: opacity 0.3s ease, transform 0.2s ease; - white-space: nowrap; + padding: 0.6rem; + border-radius: 8px; + transition: background-color 0.2s ease, transform 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + font-weight: 500; + color: white; } -.edit-btn { - background: #1db954; - color: #ffffff; -} - -.edit-search-btn { - background: #2d6db5; - color: #ffffff; -} - -.delete-btn { - background: #ff5555; - color: #ffffff; +.credential-actions button img { + width: 20px; + height: 20px; + filter: brightness(0) invert(1); } .credential-actions button:hover { - opacity: 0.9; - transform: translateY(-1px); + transform: translateY(-2px); +} + +.credential-actions button.delete-btn { + color: #ff5555; +} + +.credential-actions button.delete-btn:hover { + background-color: rgba(192, 57, 43, 0.2); +} + +.credential-actions button.edit-btn:hover { + background-color: rgba(52, 152, 219, 0.2); } /* Credentials Form */ @@ -375,10 +623,10 @@ input:checked + .slider:before { padding: 0.8rem 1.5rem; border: none; border-radius: 25px; - width: 100%; cursor: pointer; font-weight: 500; transition: background 0.3s ease, transform 0.2s ease; + margin-top: 1.5rem; } .save-btn:hover { @@ -386,23 +634,39 @@ input:checked + .slider:before { transform: translateY(-2px); } -/* Error Messages */ +/* Error Messages - Hidden by default */ #configError { + background-color: rgba(192, 57, 43, 0.1); color: #ff5555; - margin-top: 1rem; - text-align: center; + margin-top: 1.5rem; + padding: 1rem; + border-radius: 8px; + border-left: 3px solid #ff5555; font-size: 0.9rem; - min-height: 1.2rem; + display: none; } -/* Success Messages */ +/* Show the messages when they have content */ +#configError:not(:empty) { + display: block; +} + +/* Success Messages - Hidden by default */ #configSuccess { + background-color: rgba(46, 204, 113, 0.1); color: #1db954; - margin-top: 1rem; - text-align: center; + margin-top: 1.5rem; + padding: 1rem; + border-radius: 8px; + border-left: 3px solid #1db954; font-size: 0.9rem; - min-height: 1.2rem; font-weight: 500; + display: none; +} + +/* Show the messages when they have content */ +#configSuccess:not(:empty) { + display: block; } /* MOBILE RESPONSIVENESS */ @@ -418,16 +682,7 @@ input:checked + .slider:before { } /* Increase touch target sizes for buttons and selects */ - .back-button { - width: 100%; - text-align: center; - padding: 0.8rem; - } - - #spotifyAccountSelect, - #deezerAccountSelect, - #spotifyQualitySelect, - #deezerQualitySelect { + .form-select { padding: 0.8rem 2rem 0.8rem 1rem; font-size: 0.9rem; } @@ -448,31 +703,28 @@ input:checked + .slider:before { gap: 0.75rem; } + .credential-info { + width: 100%; + margin-bottom: 1rem; + } + .credential-actions { width: 100%; display: flex; - justify-content: space-between; + justify-content: flex-end; flex-wrap: wrap; gap: 0.5rem; } - .credential-actions button { - flex: 1; - text-align: center; - padding: 0.7rem 0.5rem; - } - /* Adjust toggle switch size for better touch support */ .switch { - width: 50px; - height: 24px; + width: 52px; + height: 26px; } .slider:before { - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; + height: 20px; + width: 20px; } } @@ -482,25 +734,60 @@ input:checked + .slider:before { } .account-config, + .credentials-list, .credentials-form { padding: 1rem; + border-radius: 8px; } - .form-group input { - padding: 0.7rem; + .section-title { + font-size: 1.3rem; } - .save-btn, - .back-button { + .config-item label { + font-size: 0.95rem; + } + + .form-select, + .form-input { + padding: 0.7rem 1.8rem 0.7rem 0.8rem; + font-size: 0.9rem; + } + + .save-btn { + width: 100%; padding: 0.7rem; font-size: 0.9rem; } - /* Reduce dropdown padding for very small screens */ - #spotifyAccountSelect, - #deezerAccountSelect, - #spotifyQualitySelect, - #deezerQualitySelect { - padding: 0.7rem 1.8rem 0.7rem 0.8rem; + /* Position floating icons a bit closer to the edges on small screens */ + .back-button.floating-icon { + width: 60px; + height: 60px; + left: 16px; + bottom: 16px; + } + + .back-button.floating-icon img { + width: 28px; + height: 28px; + } + + /* Queue icon mobile styles */ + .queue-icon.floating-icon { + width: 50px; + height: 50px; + right: 16px; + bottom: 16px; + } + + .queue-icon.floating-icon img { + width: 22px; + height: 22px; + } + + .queue-icon .queue-x { + font-size: 24px; + line-height: 20px; } } diff --git a/static/css/main/base.css b/static/css/main/base.css new file mode 100644 index 0000000..1f71df9 --- /dev/null +++ b/static/css/main/base.css @@ -0,0 +1,457 @@ +/* Spotizerr Base Styles + Provides consistent styling across all pages */ + +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +:root { + /* Main colors */ + --color-background: #121212; + --color-background-gradient: linear-gradient(135deg, #121212, #1e1e1e); + --color-surface: #1c1c1c; + --color-surface-hover: #2a2a2a; + --color-border: #2a2a2a; + + /* Text colors */ + --color-text-primary: #ffffff; + --color-text-secondary: #b3b3b3; + --color-text-tertiary: #757575; + + /* Brand colors */ + --color-primary: #1db954; + --color-primary-hover: #17a44b; + --color-error: #c0392b; + --color-success: #2ecc71; + + /* Spacing */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + + /* Shadow */ + --shadow-sm: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); + --shadow-md: 0 4px 6px rgba(0,0,0,0.1); + --shadow-lg: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-round: 50%; +} + +body { + background: var(--color-background-gradient); + color: var(--color-text-primary); + min-height: 100vh; + line-height: 1.4; +} + +a { + color: inherit; + text-decoration: none; + transition: color 0.2s ease; +} + +a:hover, a:focus { + color: var(--color-primary); + text-decoration: underline; +} + +/* Container for main content */ +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: var(--space-lg); + position: relative; + z-index: 1; +} + +/* Card component */ +.card { + background: var(--color-surface); + border-radius: var(--radius-md); + padding: var(--space-md); + box-shadow: var(--shadow-sm); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +/* Button variants */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-sm) var(--space-md); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s ease, transform 0.2s ease; + background-color: var(--color-surface-hover); + color: var(--color-text-primary); +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--color-primary-hover); +} + +/* Icon button */ +.btn-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-round); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.btn-icon:hover { + background-color: var(--color-surface-hover); +} + +.btn-icon img { + width: 20px; + height: 20px; + filter: brightness(0) invert(1); +} + +/* Queue icon styling */ +.queue-icon { + background-color: transparent; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.queue-icon:hover { + background-color: var(--color-surface-hover); +} + +/* Floating icons (queue and settings) */ +.floating-icon { + position: fixed; + width: 56px; + height: 56px; + bottom: 20px; + background-color: var(--color-primary); + border-radius: var(--radius-round); + box-shadow: var(--shadow-lg); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, background-color 0.2s ease; + text-decoration: none !important; +} + +.floating-icon:hover, +.floating-icon:active { + background-color: var(--color-primary-hover); + transform: scale(1.05); +} + +.floating-icon img { + width: 24px; + height: 24px; + filter: brightness(0) invert(1); + margin: 0; +} + +/* Settings icon - bottom left */ +.settings-icon { + left: 20px; +} + +/* Queue icon - bottom right */ +.queue-icon { + right: 20px; +} + +/* Home button */ +.home-btn { + background-color: transparent; + border: none; + cursor: pointer; + padding: 0; +} + +.home-btn img { + width: 32px; + height: 32px; + filter: invert(1); + transition: transform 0.2s ease; +} + +.home-btn:hover img { + transform: scale(1.05); +} + +.home-btn:active img { + transform: scale(0.98); +} + +/* When home button is used as a floating button */ +.floating-icon.home-btn { + background-color: var(--color-primary); + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.floating-icon.home-btn img { + width: 24px; + height: 24px; + filter: brightness(0) invert(1); + margin: 0; +} + +/* Download button */ +.download-btn { + background-color: var(--color-primary); + border: none; + border-radius: var(--radius-sm); + padding: 0.7rem 1.2rem; + cursor: pointer; + font-weight: 600; + transition: background-color 0.2s ease, transform 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + color: white; +} + +.download-btn img { + width: 18px; + height: 18px; + margin-right: 8px; + filter: brightness(0) invert(1); +} + +.download-btn:hover { + background-color: var(--color-primary-hover); + transform: translateY(-2px); +} + +.download-btn:active { + transform: scale(0.98); +} + +.download-btn--circle { + width: 40px; + height: 40px; + border-radius: var(--radius-round); + padding: 0; +} + +.download-btn--circle img { + margin-right: 0; +} + +/* Header patterns */ +.content-header { + display: flex; + align-items: center; + gap: var(--space-md); + margin-bottom: var(--space-xl); + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--color-border); +} + +.header-image { + width: 180px; + height: 180px; + object-fit: cover; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} + +.header-info { + flex: 1; +} + +.header-title { + font-size: 2rem; + margin-bottom: var(--space-sm); + font-weight: 700; +} + +.header-subtitle { + font-size: 1.1rem; + color: var(--color-text-secondary); + margin-bottom: var(--space-xs); +} + +.header-actions { + display: flex; + gap: var(--space-sm); + margin-top: var(--space-md); +} + +/* Track list styling */ +.tracks-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-top: var(--space-md); +} + +.track-item { + display: grid; + grid-template-columns: 40px 1fr auto auto; + align-items: center; + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + transition: background-color 0.2s ease; +} + +.track-item:hover { + background-color: var(--color-surface-hover); +} + +/* Utility classes */ +.text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hidden { + display: none !important; +} + +.flex { + display: flex; +} + +.flex-column { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-sm { + gap: var(--space-sm); +} + +.gap-md { + gap: var(--space-md); +} + +/* Loading and error states */ +.loading, +.error { + width: 100%; + text-align: center; + font-size: 1rem; + padding: var(--space-md); +} + +.error { + color: var(--color-error); +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .content-header { + flex-direction: column; + text-align: center; + } + + .header-image { + width: 150px; + height: 150px; + } + + .header-title { + font-size: 1.75rem; + } + + .track-item { + grid-template-columns: 30px 1fr auto; + } +} + +@media (max-width: 480px) { + .app-container { + padding: var(--space-md); + } + + .header-image { + width: 120px; + height: 120px; + } + + .header-title { + font-size: 1.5rem; + } + + .header-subtitle { + font-size: 0.9rem; + } + + .header-actions { + flex-direction: column; + width: 100%; + } + + .download-btn { + width: 100%; + } + + /* Adjust floating icons size for very small screens */ + .floating-icon { + width: 60px; + height: 60px; + } + + .floating-icon img { + width: 28px; + height: 28px; + } + + /* Position floating icons a bit closer to the edges on small screens */ + .settings-icon { + left: 16px; + bottom: 16px; + } + + .queue-icon { + right: 16px; + bottom: 16px; + } +} \ No newline at end of file diff --git a/static/css/main/icons.css b/static/css/main/icons.css index bd34314..adac5cf 100644 --- a/static/css/main/icons.css +++ b/static/css/main/icons.css @@ -20,6 +20,46 @@ padding: 4px; } +/* Style for the skull icon in the Cancel all button */ +.skull-icon { + width: 16px; + height: 16px; + margin-right: 8px; + vertical-align: middle; + filter: brightness(0) invert(1); /* Makes icon white */ + transition: transform 0.3s ease; +} + +#cancelAllBtn:hover .skull-icon { + transform: rotate(-10deg) scale(1.2); + animation: skullShake 0.5s infinite alternate; +} + +@keyframes skullShake { + 0% { transform: rotate(-5deg); } + 100% { transform: rotate(5deg); } +} + +/* Style for the X that appears when the queue is visible */ +.queue-x { + font-size: 28px; + font-weight: bold; + color: white; + line-height: 24px; + display: inline-block; + transform: translateY(-2px); +} + +/* Queue icon with red tint when X is active */ +.queue-icon-active { + background-color: #d13838 !important; /* Red background for active state */ + transition: background-color 0.3s ease; +} + +.queue-icon-active:hover { + background-color: #e04c4c !important; /* Lighter red on hover */ +} + .download-icon, .type-icon, .toggle-chevron { diff --git a/static/css/main/main.css b/static/css/main/main.css index 8dbdc9c..5c069c2 100644 --- a/static/css/main/main.css +++ b/static/css/main/main.css @@ -24,18 +24,35 @@ body { /* LOADING & ERROR STATES */ .loading, -.error { +.error, +.success { width: 100%; text-align: center; font-size: 1rem; padding: 1rem; + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + border-radius: 8px; + max-width: 80%; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); } .error { - color: #c0392b; + color: #fff; + background-color: rgba(192, 57, 43, 0.9); } -/* SEARCH HEADER COMPONENT */ +.success { + color: #fff; + background-color: rgba(46, 204, 113, 0.9); +} + +/* Main search page specific styles */ + +/* Search header improvements */ .search-header { display: flex; align-items: center; @@ -43,11 +60,17 @@ body { margin-bottom: 30px; position: sticky; top: 0; - background: rgba(18, 18, 18, 1); + background: rgba(18, 18, 18, 0.95); backdrop-filter: blur(10px); padding: 20px 0; z-index: 100; - border-bottom: 1px solid #2a2a2a; + border-bottom: 1px solid var(--color-border); +} + +.search-input-container { + display: flex; + flex: 1; + gap: 10px; } .search-input { @@ -55,72 +78,121 @@ body { padding: 12px 20px; border: none; border-radius: 25px; - background: #2a2a2a; - color: #ffffff; + background: var(--color-surface); + color: var(--color-text-primary); font-size: 16px; outline: none; - transition: background-color 0.3s ease; + transition: background-color 0.3s ease, box-shadow 0.3s ease; } .search-input:focus { - background: #333333; + background: var(--color-surface-hover); + box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.3); } .search-type { padding: 12px 15px; - background: #2a2a2a; + background: var(--color-surface); border: none; border-radius: 25px; - color: #ffffff; + color: var(--color-text-primary); cursor: pointer; transition: background-color 0.3s ease; + min-width: 100px; } -.search-type:hover { - background: #3a3a3a; +.search-type:hover, +.search-type:focus { + background: var(--color-surface-hover); } .search-button { - padding: 12px 30px; - background-color: #1db954; + padding: 12px 25px; + background-color: var(--color-primary); border: none; border-radius: 25px; - color: #ffffff; - font-weight: bold; + color: white; + font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.2s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.search-button img { + width: 18px; + height: 18px; + filter: brightness(0) invert(1); } .search-button:hover { - background-color: #1ed760; + background-color: var(--color-primary-hover); transform: translateY(-2px); } -/* RESULTS GRID COMPONENT – Minimalistic Version */ +/* Empty state styles */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 60vh; + text-align: center; +} + +.empty-state-content { + max-width: 450px; +} + +.empty-state-icon { + width: 80px; + height: 80px; + margin-bottom: 1.5rem; + opacity: 0.7; +} + +.empty-state h2 { + font-size: 1.75rem; + margin-bottom: 1rem; + background: linear-gradient(90deg, var(--color-primary), #2ecc71); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.empty-state p { + color: var(--color-text-secondary); + font-size: 1rem; + line-height: 1.5; +} + +/* Results grid improvement */ .results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 15px; + gap: 20px; + margin-top: 20px; } -/* Each result card now features a clean, flat design with minimal decoration */ +/* Result card style */ .result-card { - background: #1c1c1c; /* A uniform dark background */ - border: 1px solid #2a2a2a; /* A subtle border for separation */ - border-radius: 4px; /* Slight rounding for a modern look */ + background: var(--color-surface); + border-radius: var(--radius-md); overflow: hidden; display: flex; flex-direction: column; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: var(--shadow-sm); + height: 100%; } .result-card:hover { - background-color: #2a2a2a; /* Lightens the card on hover */ - transform: translateY(-2px); /* A gentle lift effect */ + transform: translateY(-5px); + box-shadow: var(--shadow-md); } -/* Album/Art image wrapper – Maintains aspect ratio and a clean presentation */ +/* Album art styling */ .album-art-wrapper { position: relative; width: 100%; @@ -130,7 +202,7 @@ body { .album-art-wrapper::before { content: ""; display: block; - padding-top: 100%; /* 1:1 aspect ratio */ + padding-top: 100%; } .album-art { @@ -140,15 +212,19 @@ body { width: 100%; height: 100%; object-fit: cover; - transition: opacity 0.2s ease; + transition: transform 0.3s ease; } -/* Text details are kept simple and legible */ +.result-card:hover .album-art { + transform: scale(1.05); +} + +/* Track title and details */ .track-title { - padding: 0.75rem 1rem; + padding: 1rem 1rem 0.5rem; font-size: 1rem; - font-weight: bold; - color: #ffffff; + font-weight: 600; + color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -157,128 +233,106 @@ body { .track-artist { padding: 0 1rem; font-size: 0.9rem; - color: #aaaaaa; - margin-top: 0.25rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 0.75rem; } .track-details { padding: 0.75rem 1rem; font-size: 0.85rem; - color: #bbbbbb; - border-top: 1px solid #2a2a2a; + color: var(--color-text-tertiary); + border-top: 1px solid var(--color-border); display: flex; justify-content: space-between; align-items: center; + margin-top: auto; } -.duration { - font-style: italic; - color: #999; -} - -/* Centered Download Button styling */ +/* Download button within result cards */ .download-btn { - margin: 0.75rem 1rem 1rem; - padding: 0.5rem 1rem; - background-color: #1db954; - color: #ffffff; - border: none; - border-radius: 4px; - font-size: 0.95rem; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - display: block; - text-align: center; - width: calc(100% - 2rem); + margin: 0 1rem 1rem; + max-width: calc(100% - 2rem); /* Ensure button doesn't overflow container */ + width: auto; /* Allow button to shrink if needed */ + font-size: 0.9rem; /* Slightly smaller font size */ + padding: 0.6rem 1rem; /* Reduce padding slightly */ + overflow: hidden; /* Hide overflow */ + text-overflow: ellipsis; /* Add ellipsis for long text */ + white-space: nowrap; /* Prevent wrapping */ } -.download-btn:hover { - background-color: #17a44b; - transform: scale(1.02); -} - -/* ARTIST DOWNLOAD OPTIONS */ -.artist-download-buttons { - border-top: 1px solid #2a2a2a; - padding: 0.5rem 1rem; -} - -.options-toggle { - width: 100%; - background: none; - border: none; - color: #b3b3b3; - padding: 8px 16px; - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - transition: color 0.2s ease; -} - -.options-toggle:hover { - color: #ffffff; -} - -.download-options-container { - margin-top: 0.5rem; -} - -.secondary-options { - display: none; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.5rem; -} - -.secondary-options.expanded { - display: flex; -} - -.option-btn { - flex: 1; - background-color: #2a2a2a; - color: #ffffff; - padding: 0.4rem 0.6rem; - border: none; - border-radius: 4px; - font-size: 0.85rem; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; -} - -.option-btn:hover { - background-color: #3a3a3a; - transform: translateY(-1px); -} - -/* MOBILE RESPONSIVENESS */ -@media (max-width: 600px) { +/* Mobile responsiveness */ +@media (max-width: 768px) { .search-header { flex-wrap: wrap; - justify-content: center; - padding: 10px 0; + padding: 15px 0; + gap: 12px; } - .search-input, - .search-type, - .search-button { + .search-input-container { flex: 1 1 100%; - margin-bottom: 10px; + order: 1; } - .search-type, .search-button { - padding: 10px; - font-size: 15px; + order: 2; + flex: 1; } .results-grid { - justify-content: center; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 15px; } - .result-card { - width: 90%; - margin: 0 auto; + /* Smaller download button for mobile */ + .download-btn { + padding: 0.5rem 0.8rem; + font-size: 0.85rem; + } +} + +@media (max-width: 480px) { + .search-header { + padding: 10px 0; + } + + .search-type { + min-width: 80px; + padding: 12px 10px; + } + + .search-button { + padding: 12px 15px; + } + + .results-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + } + + .track-title, .track-artist { + font-size: 0.9rem; + } + + .track-details { + font-size: 0.8rem; + } + + /* Even smaller download button for very small screens */ + .download-btn { + padding: 0.4rem 0.7rem; + font-size: 0.8rem; + margin: 0 0.8rem 0.8rem; + max-width: calc(100% - 1.6rem); + } + + .empty-state h2 { + font-size: 1.5rem; + } + + .empty-state p { + font-size: 0.9rem; } } diff --git a/static/css/playlist/playlist.css b/static/css/playlist/playlist.css index 500b65b..c684386 100644 --- a/static/css/playlist/playlist.css +++ b/static/css/playlist/playlist.css @@ -392,3 +392,70 @@ a:focus { .download-btn--circle:active { transform: scale(0.98); } + +/* Playlist page specific styles */ + +/* Playlist description */ +.playlist-description { + font-size: 0.9rem; + color: var(--color-text-secondary); + margin-top: 0.75rem; + max-width: 90%; + line-height: 1.5; +} + +/* Additional column for album in playlist tracks */ +.track-album { + font-size: 0.9rem; + color: var(--color-text-secondary); + margin-right: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +/* Overriding the track layout for playlists to include the album column */ +.track { + grid-template-columns: 40px 1fr 1fr auto auto; +} + +/* Style for the download albums button */ +#downloadAlbumsBtn { + background-color: rgba(255, 255, 255, 0.1); +} + +#downloadAlbumsBtn:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +/* Mobile responsiveness adjustments */ +@media (max-width: 1024px) { + .track { + grid-template-columns: 40px 1fr auto auto; + } + + .track-album { + display: none; + } +} + +@media (max-width: 768px) { + .playlist-description { + max-width: 100%; + } + + #downloadAlbumsBtn { + margin-top: 0.5rem; + } +} + +@media (max-width: 480px) { + .track { + grid-template-columns: 30px 1fr auto; + } + + .playlist-description { + margin-bottom: 1rem; + } +} diff --git a/static/css/queue/queue.css b/static/css/queue/queue.css index 66ef27f..14ba98d 100644 --- a/static/css/queue/queue.css +++ b/static/css/queue/queue.css @@ -51,18 +51,29 @@ /* Cancel all button styling */ #cancelAllBtn { - background: #ff5555; + background: #8b0000; /* Dark blood red */ border: none; color: #fff; padding: 8px 12px; border-radius: 4px; cursor: pointer; - transition: background 0.3s ease; + transition: background 0.3s ease, transform 0.2s ease; font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } #cancelAllBtn:hover { - background: #ff7777; + background: #a30000; /* Slightly lighter red on hover */ + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} + +#cancelAllBtn:active { + transform: scale(0.98); } /* Close button for the queue sidebar */ @@ -78,11 +89,16 @@ color: #ffffff; font-size: 20px; cursor: pointer; - transition: background-color 0.3s ease; + transition: background-color 0.3s ease, transform 0.2s ease; } .close-btn:hover { background-color: #333; + transform: scale(1.05); +} + +.close-btn:active { + transform: scale(0.95); } /* Container for all queue items */ @@ -90,6 +106,24 @@ /* Allow the container to fill all available space in the sidebar */ flex: 1; overflow-y: auto; + padding-right: 5px; /* Add slight padding for scrollbar */ + scrollbar-width: thin; + scrollbar-color: #1DB954 rgba(255, 255, 255, 0.1); +} + +/* Custom scrollbar styles */ +#queueItems::-webkit-scrollbar { + width: 6px; +} + +#queueItems::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; +} + +#queueItems::-webkit-scrollbar-thumb { + background-color: #1DB954; + border-radius: 10px; } /* Each download queue item */ @@ -98,33 +132,72 @@ padding: 15px; border-radius: 8px; margin-bottom: 15px; - transition: background-color 0.3s ease, transform 0.2s ease; + transition: all 0.3s ease; display: flex; flex-direction: column; gap: 6px; + position: relative; + border-left: 4px solid transparent; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +/* Animation only for newly added items */ +.queue-item-new { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } } .queue-item:hover { background-color: #333; transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } /* Title text in a queue item */ .queue-item .title { - font-weight: 500; + font-weight: 600; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #fff; + font-size: 14px; } /* Type indicator (e.g. track, album) */ .queue-item .type { - font-size: 12px; + font-size: 11px; color: #1DB954; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.7px; + font-weight: 600; + background-color: rgba(29, 185, 84, 0.1); + padding: 3px 6px; + border-radius: 4px; + display: inline-block; + width: fit-content; +} + +/* Album type - for better visual distinction */ +.queue-item .type.album { + color: #4a90e2; + background-color: rgba(74, 144, 226, 0.1); +} + +/* Track type */ +.queue-item .type.track { + color: #1DB954; + background-color: rgba(29, 185, 84, 0.1); +} + +/* Playlist type */ +.queue-item .type.playlist { + color: #e67e22; + background-color: rgba(230, 126, 34, 0.1); } /* Log text for status messages */ @@ -133,19 +206,22 @@ color: #b3b3b3; line-height: 1.4; font-family: 'SF Mono', Menlo, monospace; + padding: 8px 0; + word-break: break-word; } /* Optional state indicators for each queue item */ -.queue-item--complete { - border-left: 4px solid #1DB954; +.queue-item--complete, +.queue-item.download-success { + border-left-color: #1DB954; } .queue-item--error { - border-left: 4px solid #ff5555; + border-left-color: #ff5555; } .queue-item--processing { - border-left: 4px solid #4a90e2; + border-left-color: #4a90e2; } /* Progress bar for downloads */ @@ -155,13 +231,16 @@ width: 0; transition: width 0.3s ease; margin-top: 8px; + border-radius: 2px; } /* Progress percentage text */ .progress-percent { text-align: right; font-weight: bold; + font-size: 12px; color: #1DB954; + margin-top: 4px; } /* Optional status message colors (if using state classes) */ @@ -197,12 +276,14 @@ /* Loading spinner style */ .loading-spinner { display: inline-block; - width: 16px; - height: 16px; - border: 3px solid rgba(255, 255, 255, 0.3); + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 50%; border-top-color: #1DB954; animation: spin 1s ease-in-out infinite; + margin-right: 6px; + vertical-align: middle; } @keyframes spin { @@ -220,6 +301,15 @@ /* Optionally constrain the overall size */ max-width: 24px; max-height: 24px; + position: absolute; + top: 10px; + right: 10px; + opacity: 0.7; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.cancel-btn:hover { + opacity: 1; } .cancel-btn img { @@ -237,28 +327,65 @@ transform: scale(0.9); } +/* Group header for multiple albums from same artist */ +.queue-group-header { + font-size: 14px; + color: #b3b3b3; + margin: 15px 0 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: space-between; +} + +.queue-group-header span { + display: flex; + align-items: center; +} + +.queue-group-header span::before { + content: ''; + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #1DB954; + margin-right: 8px; +} + /* ------------------------------- */ /* FOOTER & "SHOW MORE" BUTTON */ /* ------------------------------- */ #queueFooter { text-align: center; - padding-top: 10px; + padding-top: 15px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 10px; } #queueFooter button { background: #1DB954; border: none; - padding: 8px 16px; - border-radius: 4px; + padding: 10px 18px; + border-radius: 20px; color: #fff; cursor: pointer; - transition: background 0.3s ease; + transition: all 0.3s ease; font-size: 14px; + font-weight: 500; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); } #queueFooter button:hover { background: #17a448; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +#queueFooter button:active { + transform: scale(0.98); } /* -------------------------- */ @@ -287,35 +414,77 @@ /* Hover state for all error buttons */ .error-buttons button:hover { background: #333; + transform: translateY(-1px); +} + +.error-buttons button:active { + transform: scale(0.98); } /* Specific styles for the Close (X) error button */ .close-error-btn { - background: #ff5555; + background: #ff5555 !important; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; - border-radius: 50%; - font-size: 20px; - padding: 0; + border-radius: 50% !important; + font-size: 18px !important; + padding: 0 !important; } .close-error-btn:hover { - background: #ff7777; + background: #ff7777 !important; } /* Specific styles for the Retry button */ .retry-btn { - background: #1DB954; - padding: 8px 16px; - border-radius: 4px; - font-weight: bold; + background: #1DB954 !important; + padding: 8px 16px !important; + border-radius: 20px !important; + font-weight: 500 !important; + flex: 1; } .retry-btn:hover { - background: #17a448; + background: #17a448 !important; +} + +/* Empty queue state */ +.queue-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 200px; + color: #b3b3b3; + text-align: center; + padding: 20px; +} + +.queue-empty img { + width: 60px; + height: 60px; + margin-bottom: 15px; + opacity: 0.6; +} + +.queue-empty p { + font-size: 14px; + line-height: 1.5; +} + +/* Error notification in queue */ +.queue-error { + background-color: rgba(192, 57, 43, 0.1); + color: #ff5555; + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 15px; + font-size: 14px; + border-left: 3px solid #ff5555; + animation: fadeIn 0.3s ease; } /* ------------------------------- */ @@ -336,8 +505,10 @@ /* Adjust header and title for smaller screens */ .sidebar-header { - flex-direction: column; - align-items: flex-start; + flex-direction: row; + align-items: center; + padding-bottom: 12px; + margin-bottom: 15px; } .sidebar-header h2 { @@ -362,4 +533,22 @@ .queue-item .type { font-size: 12px; } + + #cancelAllBtn { + padding: 6px 10px; + font-size: 12px; + } + + .error-buttons { + flex-direction: row; + } + + .close-error-btn { + width: 28px; + height: 28px; + } + + .retry-btn { + padding: 6px 12px !important; + } } diff --git a/static/css/track/track.css b/static/css/track/track.css index 35f4161..9ee57bb 100644 --- a/static/css/track/track.css +++ b/static/css/track/track.css @@ -235,7 +235,7 @@ a:focus { justify-content: center; } -/* Style the download button’s icon */ +/* Style the download button's icon */ .download-btn img { width: 20px; height: 20px; @@ -260,3 +260,101 @@ a:focus { text-align: center; } } + +/* Track page specific styles */ + +/* Track details formatting */ +.track-details { + display: flex; + gap: 1rem; + margin-top: 0.5rem; + color: var(--color-text-secondary); + font-size: 0.9rem; +} + +.track-detail-item { + display: flex; + align-items: center; +} + +/* Make explicit tag stand out if needed */ +#track-explicit:not(:empty) { + background-color: rgba(255, 255, 255, 0.1); + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Loading indicator animation */ +.loading-indicator { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} + +.loading-indicator:after { + content: " "; + display: block; + width: 40px; + height: 40px; + margin: 8px; + border-radius: 50%; + border: 4px solid var(--color-primary); + border-color: var(--color-primary) transparent var(--color-primary) transparent; + animation: loading-rotation 1.2s linear infinite; +} + +@keyframes loading-rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Modern gradient for the track name */ +#track-name a { + background: linear-gradient(90deg, var(--color-primary), #2ecc71); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + display: inline-block; +} + +/* Ensure proper spacing for album and artist links */ +#track-artist a, +#track-album a { + transition: color 0.2s ease, text-decoration 0.2s ease; +} + +#track-artist a:hover, +#track-album a:hover { + color: var(--color-primary); +} + +/* Mobile-specific adjustments */ +@media (max-width: 768px) { + .track-details { + flex-direction: column; + gap: 0.25rem; + margin-bottom: 1rem; + } + + #track-name a { + font-size: 1.75rem; + } +} + +@media (max-width: 480px) { + #track-name a { + font-size: 1.5rem; + } + + .track-details { + margin-bottom: 1.5rem; + } +} diff --git a/static/images/album.svg b/static/images/album.svg new file mode 100644 index 0000000..c0c1074 --- /dev/null +++ b/static/images/album.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/static/images/arrow-left.svg b/static/images/arrow-left.svg new file mode 100644 index 0000000..febbadc --- /dev/null +++ b/static/images/arrow-left.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/images/music.svg b/static/images/music.svg new file mode 100644 index 0000000..054d6da --- /dev/null +++ b/static/images/music.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/static/images/queue-empty.svg b/static/images/queue-empty.svg new file mode 100644 index 0000000..5695b55 --- /dev/null +++ b/static/images/queue-empty.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/images/search.svg b/static/images/search.svg new file mode 100644 index 0000000..695916e --- /dev/null +++ b/static/images/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index d83cdec..4493080 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,428 +1,401 @@ // main.js import { downloadQueue } from './queue.js'; -document.addEventListener('DOMContentLoaded', () => { - const searchButton = document.getElementById('searchButton'); +document.addEventListener('DOMContentLoaded', function() { + // DOM elements const searchInput = document.getElementById('searchInput'); + const searchButton = document.getElementById('searchButton'); + const searchType = document.getElementById('searchType'); + const resultsContainer = document.getElementById('resultsContainer'); const queueIcon = document.getElementById('queueIcon'); - const searchType = document.getElementById('searchType'); // Ensure this element exists in your HTML + const emptyState = document.getElementById('emptyState'); + const loadingResults = document.getElementById('loadingResults'); - // Preselect the saved search type if available - const storedSearchType = localStorage.getItem('searchType'); - if (storedSearchType && searchType) { - searchType.value = storedSearchType; - } - - // Save the search type to local storage whenever it changes - if (searchType) { - searchType.addEventListener('change', () => { - localStorage.setItem('searchType', searchType.value); + // Initialize the queue + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); }); } - // Initialize queue icon - if (queueIcon) { - queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility()); - } - - // Search functionality + // Add event listeners if (searchButton) { searchButton.addEventListener('click', performSearch); } - + if (searchInput) { - searchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') performSearch(); + searchInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + performSearch(); + } }); + + // Auto-detect and handle pasted Spotify URLs + searchInput.addEventListener('input', function(e) { + const inputVal = e.target.value.trim(); + if (isSpotifyUrl(inputVal)) { + const details = getSpotifyResourceDetails(inputVal); + if (details) { + searchType.value = details.type; + } + } + }); + } + + // Check for URL parameters + const urlParams = new URLSearchParams(window.location.search); + const query = urlParams.get('q'); + const type = urlParams.get('type'); + + if (query) { + searchInput.value = query; + if (type && ['track', 'album', 'playlist', 'artist'].includes(type)) { + searchType.value = type; + } + performSearch(); + } else { + // Show empty state if no query + showEmptyState(true); + } + + /** + * Performs the search based on input values + */ + async function performSearch() { + const query = searchInput.value.trim(); + if (!query) return; + + // Handle direct Spotify URLs + if (isSpotifyUrl(query)) { + const details = getSpotifyResourceDetails(query); + if (details && details.id) { + // Redirect to the appropriate page + window.location.href = `/${details.type}/${details.id}`; + return; + } + } + + // Update URL without reloading page + const newUrl = `${window.location.pathname}?q=${encodeURIComponent(query)}&type=${searchType.value}`; + window.history.pushState({ path: newUrl }, '', newUrl); + + // Show loading state + showEmptyState(false); + showLoading(true); + resultsContainer.innerHTML = ''; + + try { + const url = `/api/search?q=${encodeURIComponent(query)}&search_type=${searchType.value}&limit=40`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const data = await response.json(); + + // Hide loading indicator + showLoading(false); + + // Render results + if (data && data.items && data.items.length > 0) { + resultsContainer.innerHTML = ''; + + data.items.forEach((item, index) => { + if (!item) return; // Skip null/undefined items + + const cardElement = createResultCard(item, searchType.value, index); + resultsContainer.appendChild(cardElement); + }); + + // Attach download handlers to the newly created cards + attachDownloadListeners(data.items); + } else { + // No results found + resultsContainer.innerHTML = ` +
+

No results found for "${query}"

+
+ `; + } + } catch (error) { + console.error('Error:', error); + showLoading(false); + resultsContainer.innerHTML = ` +
+

Error searching: ${error.message}

+
+ `; + } + } + + /** + * Attaches download handlers to result cards + */ + function attachDownloadListeners(items) { + document.querySelectorAll('.download-btn').forEach((btn, index) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + + // Get the corresponding item + const item = items[index]; + if (!item) return; + + const type = searchType.value; + let url; + + // Determine the URL based on item type + if (item.external_urls && item.external_urls.spotify) { + url = item.external_urls.spotify; + } else if (item.href) { + url = item.href; + } else { + showError('Could not determine download URL'); + return; + } + + // Prepare metadata for the download + const metadata = { + name: item.name || 'Unknown', + artist: item.artists ? item.artists[0]?.name : undefined + }; + + // Disable the button and update text + btn.disabled = true; + + // For artist downloads, show a different message since it will queue multiple albums + if (type === 'artist') { + btn.innerHTML = 'Queueing albums...'; + } else { + btn.innerHTML = 'Queueing...'; + } + + // Start the download + startDownload(url, type, metadata, item.album ? item.album.album_type : null) + .then(() => { + // For artists, show how many albums were queued + if (type === 'artist') { + btn.innerHTML = 'Albums queued!'; + // Open the queue automatically for artist downloads + downloadQueue.toggleVisibility(true); + } else { + btn.innerHTML = 'Queued!'; + } + }) + .catch((error) => { + btn.disabled = false; + btn.innerHTML = 'Download'; + showError('Failed to queue download: ' + error.message); + }); + }); + }); + } + + /** + * Starts the download process via API + */ + async function startDownload(url, type, item, albumType) { + if (!url || !type) { + showError('Missing URL or type for download'); + return; + } + + const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer'; + let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`; + + // Add name and artist if available for better progress display + if (item.name) { + apiUrl += `&name=${encodeURIComponent(item.name)}`; + } + if (item.artist) { + apiUrl += `&artist=${encodeURIComponent(item.artist)}`; + } + + // For artist downloads, include album_type + if (type === 'artist' && albumType) { + apiUrl += `&album_type=${encodeURIComponent(albumType)}`; + } + + try { + const response = await fetch(apiUrl); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Download request failed'); + } + + const data = await response.json(); + + // Handle artist downloads which return multiple album_prg_files + if (type === 'artist' && data.album_prg_files && Array.isArray(data.album_prg_files)) { + // Add each album to the download queue separately + data.album_prg_files.forEach(prgFile => { + downloadQueue.addDownload(item, 'album', prgFile, apiUrl); + }); + // Show success message for artist download + if (data.message) { + showSuccess(data.message); + } + } else if (data.prg_file) { + // Handle single-file downloads (tracks, albums, playlists) + downloadQueue.addDownload(item, type, data.prg_file, apiUrl); + } else { + throw new Error('Invalid response format from server'); + } + } catch (error) { + showError('Download failed: ' + (error.message || 'Unknown error')); + throw error; + } + } + + /** + * Shows an error message + */ + function showError(message) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'error'; + errorDiv.textContent = message; + document.body.appendChild(errorDiv); + + // Auto-remove after 5 seconds + setTimeout(() => errorDiv.remove(), 5000); + } + + /** + * Shows a success message + */ + function showSuccess(message) { + const successDiv = document.createElement('div'); + successDiv.className = 'success'; + successDiv.textContent = message; + document.body.appendChild(successDiv); + + // Auto-remove after 5 seconds + setTimeout(() => successDiv.remove(), 5000); + } + + /** + * Checks if a string is a valid Spotify URL + */ + function isSpotifyUrl(url) { + return url.includes('open.spotify.com') || + url.includes('spotify:') || + url.includes('link.tospotify.com'); + } + + /** + * Extracts details from a Spotify URL + */ + function getSpotifyResourceDetails(url) { + const regex = /spotify\.com\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)/; + const match = url.match(regex); + + if (match) { + return { + type: match[1], + id: match[2] + }; + } + return null; + } + + /** + * Formats milliseconds to MM:SS + */ + function msToMinutesSeconds(ms) { + if (!ms) return '0:00'; + + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, '0')}`; + } + + /** + * Creates a result card element + */ + function createResultCard(item, type, index) { + const cardElement = document.createElement('div'); + cardElement.className = 'result-card'; + + // Set cursor to pointer for clickable cards + cardElement.style.cursor = 'pointer'; + + // Get the appropriate image URL + let imageUrl = '/static/images/placeholder.jpg'; + if (item.album && item.album.images && item.album.images.length > 0) { + imageUrl = item.album.images[0].url; + } else if (item.images && item.images.length > 0) { + imageUrl = item.images[0].url; + } + + // Get the appropriate details based on type + let subtitle = ''; + let details = ''; + + switch (type) { + case 'track': + subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist'; + details = item.album ? `${item.album.name}${msToMinutesSeconds(item.duration_ms)}` : ''; + break; + case 'album': + subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist'; + details = `${item.total_tracks || 0} tracks${item.release_date ? new Date(item.release_date).getFullYear() : ''}`; + break; + case 'playlist': + subtitle = `By ${item.owner ? item.owner.display_name : 'Unknown'}`; + details = `${item.tracks && item.tracks.total ? item.tracks.total : 0} tracks`; + break; + case 'artist': + subtitle = 'Artist'; + details = item.genres ? `${item.genres.slice(0, 2).join(', ')}` : ''; + break; + } + + // Build the HTML + cardElement.innerHTML = ` +
+ ${item.name || 'Item'} +
+
${item.name || 'Unknown'}
+
${subtitle}
+
${details}
+ + `; + + // Add click event to navigate to the item's detail page + cardElement.addEventListener('click', (e) => { + // Don't trigger if the download button was clicked + if (e.target.classList.contains('download-btn') || + e.target.parentElement.classList.contains('download-btn')) { + return; + } + + if (item.id) { + window.location.href = `/${type}/${item.id}`; + } + }); + + return cardElement; + } + + /** + * Show/hide the empty state + */ + function showEmptyState(show) { + if (emptyState) { + emptyState.style.display = show ? 'flex' : 'none'; + } + } + + /** + * Show/hide the loading indicator + */ + function showLoading(show) { + if (loadingResults) { + loadingResults.classList.toggle('hidden', !show); + } } }); - -async function performSearch() { - const searchInput = document.getElementById('searchInput'); - const searchType = document.getElementById('searchType'); - const resultsContainer = document.getElementById('resultsContainer'); - - if (!searchInput || !searchType || !resultsContainer) { - console.error('Required DOM elements not found'); - return; - } - - const query = searchInput.value.trim(); - const typeValue = searchType.value; - - if (!query) { - showError('Please enter a search term'); - return; - } - - // If the query is a Spotify URL for a supported resource, redirect to our route. - if (isSpotifyUrl(query)) { - try { - const { type, id } = getSpotifyResourceDetails(query); - const supportedTypes = ['track', 'album', 'playlist', 'artist']; - if (!supportedTypes.includes(type)) - throw new Error('Unsupported URL type'); - - // Redirect to {base_url}/{type}/{id} - window.location.href = `${window.location.origin}/${type}/${id}`; - return; - } catch (error) { - showError(`Invalid Spotify URL: ${error?.message || 'Unknown error'}`); - return; - } - } - - resultsContainer.innerHTML = '
Searching...
'; - - try { - // Fetch config to get active Spotify account - const configResponse = await fetch('/api/config'); - const config = await configResponse.json(); - const mainAccount = config?.spotify || ''; - - // Add the main parameter to the search API call - const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${typeValue}&limit=50&main=${mainAccount}`); - const data = await response.json(); - if (data.error) throw new Error(data.error); - - // When mapping the items, include the index so that each card gets a data-index attribute. - const items = data.data?.[`${typeValue}s`]?.items; - if (!items?.length) { - resultsContainer.innerHTML = '
No results found
'; - return; - } - - resultsContainer.innerHTML = items - .map((item, index) => item ? createResultCard(item, typeValue, index) : '') - .filter(card => card) // Filter out empty strings - .join(''); - attachDownloadListeners(items); - } catch (error) { - showError(error?.message || 'Search failed'); - } -} - -/** - * Attaches event listeners to all download buttons (both standard and small versions). - * Instead of using the NodeList index (which can be off when multiple buttons are in one card), - * we look up the closest result card's data-index to get the correct item. - */ -function attachDownloadListeners(items) { - document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn) => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const url = e.currentTarget.dataset.url || ''; - const type = e.currentTarget.dataset.type || ''; - const albumType = e.currentTarget.dataset.albumType || ''; - // Get the parent result card and its data-index - const card = e.currentTarget.closest('.result-card'); - const idx = card ? card.getAttribute('data-index') : null; - const item = (idx !== null && items[idx]) ? items[idx] : null; - - // Remove the button or card from the UI as appropriate. - if (e.currentTarget.classList.contains('main-download')) { - if (card) card.remove(); - } else { - e.currentTarget.remove(); - } - - if (url && type) { - startDownload(url, type, item, albumType); - } - }); - }); -} - -/** - * Calls the appropriate downloadQueue method based on the type. - * For artists, this function will use the default parameters (which you can adjust) - * so that the backend endpoint (at /artist/download) receives the required query parameters. - */ -async function startDownload(url, type, item, albumType) { - if (!url || !type) { - showError('Missing URL or type for download'); - return; - } - - // Enrich the item object with the artist property. - if (item) { - if (type === 'track' || type === 'album') { - item.artist = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'; - } else if (type === 'playlist') { - item.artist = item.owner?.display_name || 'Unknown Owner'; - } else if (type === 'artist') { - item.artist = item.name || 'Unknown Artist'; - } - } else { - item = { name: 'Unknown', artist: 'Unknown Artist' }; - } - - try { - if (type === 'track') { - await downloadQueue.startTrackDownload(url, item); - } else if (type === 'playlist') { - await downloadQueue.startPlaylistDownload(url, item); - } else if (type === 'album') { - await downloadQueue.startAlbumDownload(url, item); - } else if (type === 'artist') { - // The downloadQueue.startArtistDownload should be implemented to call your - // backend artist endpoint (e.g. /artist/download) with proper query parameters. - await downloadQueue.startArtistDownload(url, item, albumType); - } else { - throw new Error(`Unsupported type: ${type}`); - } - } catch (error) { - showError('Download failed: ' + (error?.message || 'Unknown error')); - } -} - -// UI Helper Functions -function showError(message) { - const resultsContainer = document.getElementById('resultsContainer'); - if (resultsContainer) { - resultsContainer.innerHTML = `
${message || 'An error occurred'}
`; - } -} - -function isSpotifyUrl(url) { - return url && url.startsWith('https://open.spotify.com/'); -} - -/** - * Extracts the resource type and ID from a Spotify URL. - * Expected URL format: https://open.spotify.com/{type}/{id} - */ -function getSpotifyResourceDetails(url) { - if (!url) throw new Error('Empty URL provided'); - - const urlObj = new URL(url); - const pathParts = urlObj.pathname.split('/'); - if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) { - throw new Error('Invalid Spotify URL'); - } - return { - type: pathParts[1], - id: pathParts[2] - }; -} - -function msToMinutesSeconds(ms) { - if (!ms || isNaN(ms)) return '0:00'; - - const minutes = Math.floor(ms / 60000); - const seconds = ((ms % 60000) / 1000).toFixed(0); - return `${minutes}:${seconds.padStart(2, '0')}`; -} - -/** - * Create a result card for a search result. - * The additional parameter "index" is used to set a data-index attribute on the card. - */ -function createResultCard(item, type, index) { - if (!item) return ''; - - let newUrl = '#'; - try { - const spotifyUrl = item.external_urls?.spotify; - if (spotifyUrl) { - const parsedUrl = new URL(spotifyUrl); - newUrl = window.location.origin + parsedUrl.pathname; - } - } catch (e) { - console.error('Error parsing URL:', e); - } - - let imageUrl, title, subtitle, details; - - switch (type) { - case 'track': - imageUrl = item.album?.images?.[0]?.url || '/static/images/placeholder.jpg'; - title = item.name || 'Unknown Track'; - subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'; - details = ` - ${item.album?.name || 'Unknown Album'} - ${msToMinutesSeconds(item.duration_ms)} - `; - return ` -
-
- ${type} cover -
-
-
${title}
-
- - -
-
-
${subtitle}
-
${details}
-
- `; - case 'playlist': - imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg'; - title = item.name || 'Unknown Playlist'; - subtitle = item.owner?.display_name || 'Unknown Owner'; - details = ` - ${item.tracks?.total || '0'} tracks - ${item.description || 'No description'} - `; - return ` -
-
- ${type} cover -
-
-
${title}
-
- - -
-
-
${subtitle}
-
${details}
-
- `; - case 'album': - imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg'; - title = item.name || 'Unknown Album'; - subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'; - details = ` - ${item.release_date || 'Unknown release date'} - ${item.total_tracks || '0'} tracks - `; - return ` -
-
- ${type} cover -
-
-
${title}
-
- - -
-
-
${subtitle}
-
${details}
-
- `; - case 'artist': - imageUrl = (item.images && item.images.length) ? item.images[0].url : '/static/images/placeholder.jpg'; - title = item.name || 'Unknown Artist'; - subtitle = (item.genres && item.genres.length) ? item.genres.join(', ') : 'Unknown genres'; - details = `Followers: ${item.followers?.total || 'N/A'}`; - return ` -
-
- ${type} cover -
-
-
${title}
-
- - - -
-
-
${subtitle}
-
${details}
- -
-
- -
- - - -
-
-
-
- `; - default: - title = item.name || 'Unknown'; - subtitle = ''; - details = ''; - return ` -
-
- ${type} cover -
-
-
${title}
-
- - -
-
-
${subtitle}
-
${details}
-
- `; - } -} diff --git a/static/js/queue.js b/static/js/queue.js index 54920cc..fe43827 100644 --- a/static/js/queue.js +++ b/static/js/queue.js @@ -46,8 +46,10 @@ class DownloadQueue {
@@ -68,6 +70,20 @@ class DownloadQueue { const queueSidebar = document.getElementById('downloadQueue'); queueSidebar.hidden = !this.currentConfig.downloadQueueVisible; queueSidebar.classList.toggle('active', this.currentConfig.downloadQueueVisible); + + // Initialize the queue icon based on sidebar visibility + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + if (this.currentConfig.downloadQueueVisible) { + queueIcon.innerHTML = '×'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class + } else { + queueIcon.innerHTML = 'Queue Icon'; + queueIcon.setAttribute('aria-expanded', 'false'); + queueIcon.classList.remove('queue-icon-active'); // Remove red tint class + } + } } /* Event Handling */ @@ -80,16 +96,6 @@ class DownloadQueue { } }); - // Close queue when the close button is clicked. - const queueSidebar = document.getElementById('downloadQueue'); - if (queueSidebar) { - queueSidebar.addEventListener('click', async (e) => { - if (e.target.closest('.close-btn')) { - await this.toggleVisibility(); - } - }); - } - // "Cancel all" button. const cancelAllBtn = document.getElementById('cancelAllBtn'); if (cancelAllBtn) { @@ -118,13 +124,30 @@ class DownloadQueue { } /* Public API */ - async toggleVisibility() { + async toggleVisibility(force) { const queueSidebar = document.getElementById('downloadQueue'); - const isVisible = !queueSidebar.classList.contains('active'); + // If force is provided, use that value, otherwise toggle the current state + const isVisible = force !== undefined ? force : !queueSidebar.classList.contains('active'); queueSidebar.classList.toggle('active', isVisible); queueSidebar.hidden = !isVisible; + // Update the queue icon to show X when visible or queue icon when hidden + const queueIcon = document.getElementById('queueIcon'); + if (queueIcon) { + if (isVisible) { + // Replace the image with an X and add red tint + queueIcon.innerHTML = '×'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class + } else { + // Restore the original queue icon and remove red tint + queueIcon.innerHTML = 'Queue Icon'; + queueIcon.setAttribute('aria-expanded', 'false'); + queueIcon.classList.remove('queue-icon-active'); // Remove red tint class + } + } + // Persist the state locally so it survives refreshes. localStorage.setItem("downloadQueueVisible", isVisible); @@ -138,6 +161,18 @@ class DownloadQueue { // Revert UI if save failed. queueSidebar.classList.toggle('active', !isVisible); queueSidebar.hidden = isVisible; + // Also revert the icon back + if (queueIcon) { + if (!isVisible) { + queueIcon.innerHTML = '×'; + queueIcon.setAttribute('aria-expanded', 'true'); + queueIcon.classList.add('queue-icon-active'); // Add red tint class + } else { + queueIcon.innerHTML = 'Queue Icon'; + queueIcon.setAttribute('aria-expanded', 'false'); + queueIcon.classList.remove('queue-icon-active'); // Remove red tint class + } + } this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible }); this.showError('Failed to save queue visibility'); } @@ -186,6 +221,14 @@ class DownloadQueue { if (data.type) { entry.type = data.type; + + // Update type display if element exists + const typeElement = entry.element.querySelector('.type'); + if (typeElement) { + typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1); + // Update type class without triggering animation + typeElement.className = `type ${data.type}`; + } } if (!entry.requestUrl && data.original_request) { @@ -224,8 +267,16 @@ class DownloadQueue { entry.lastStatus = progress; entry.lastUpdated = Date.now(); entry.status = progress.status; - logElement.textContent = this.getStatusMessage(progress); - + + // Update status message without recreating the element + if (logElement) { + const statusMessage = this.getStatusMessage(progress); + logElement.textContent = statusMessage; + } + + // Apply appropriate CSS classes based on status + this.applyStatusClasses(entry, progress); + // Save updated status to cache. this.queueCache[entry.prgFile] = progress; localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); @@ -266,13 +317,71 @@ class DownloadQueue { intervalId: null, uniqueId: queueId, retryCount: 0, - autoRetryInterval: null + autoRetryInterval: null, + isNew: true // Add flag to track if this is a new entry }; // If cached info exists for this PRG file, use it. if (this.queueCache[prgFile]) { entry.lastStatus = this.queueCache[prgFile]; const logEl = entry.element.querySelector('.log'); - logEl.textContent = this.getStatusMessage(this.queueCache[prgFile]); + + // Special handling for error states to restore UI with buttons + if (entry.lastStatus.status === 'error') { + // Hide the cancel button if in error state + const cancelBtn = entry.element.querySelector('.cancel-btn'); + if (cancelBtn) { + cancelBtn.style.display = 'none'; + } + + // Determine if we can retry + const canRetry = entry.retryCount < this.MAX_RETRIES && entry.requestUrl; + + if (canRetry) { + // Create error UI with retry button + logEl.innerHTML = ` +
${this.getStatusMessage(entry.lastStatus)}
+
+ + +
+ `; + + // Add event listeners + logEl.querySelector('.close-error-btn').addEventListener('click', () => { + if (entry.autoRetryInterval) { + clearInterval(entry.autoRetryInterval); + entry.autoRetryInterval = null; + } + this.cleanupEntry(queueId); + }); + + logEl.querySelector('.retry-btn').addEventListener('click', async () => { + if (entry.autoRetryInterval) { + clearInterval(entry.autoRetryInterval); + entry.autoRetryInterval = null; + } + this.retryDownload(queueId, logEl); + }); + } else { + // Cannot retry - just show error with close button + logEl.innerHTML = ` +
${this.getStatusMessage(entry.lastStatus)}
+
+ +
+ `; + + logEl.querySelector('.close-error-btn').addEventListener('click', () => { + this.cleanupEntry(queueId); + }); + } + } else { + // For non-error states, just set the message text + logEl.textContent = this.getStatusMessage(entry.lastStatus); + } + + // Apply appropriate CSS classes based on cached status + this.applyStatusClasses(entry, this.queueCache[prgFile]); } return entry; } @@ -288,21 +397,53 @@ class DownloadQueue { const displayType = type.charAt(0).toUpperCase() + type.slice(1); const div = document.createElement('article'); - div.className = 'queue-item'; + div.className = 'queue-item queue-item-new'; // Add the animation class div.setAttribute('aria-live', 'polite'); div.setAttribute('aria-atomic', 'true'); div.innerHTML = `
${displayTitle}
-
${displayType}
+
${displayType}
${defaultMessage}
`; div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e)); + + // Remove the animation class after animation completes + setTimeout(() => { + div.classList.remove('queue-item-new'); + }, 300); // Match the animation duration + return div; } + // Add a helper method to apply the right CSS classes based on status + applyStatusClasses(entry, status) { + if (!entry || !entry.element || !status) return; + + // Clear existing status classes + entry.element.classList.remove('queue-item--processing', 'queue-item--error', 'download-success'); + + // Apply appropriate class based on status + if (status.status === 'processing' || status.status === 'downloading' || status.status === 'progress') { + entry.element.classList.add('queue-item--processing'); + } else if (status.status === 'error') { + entry.element.classList.add('queue-item--error'); + entry.hasEnded = true; + } else if (status.status === 'complete' || status.status === 'done') { + entry.element.classList.add('download-success'); + entry.hasEnded = true; + } else if (status.status === 'cancel' || status.status === 'interrupted') { + entry.hasEnded = true; + } + + // Special case for retry status + if (status.retrying || status.status === 'retrying') { + entry.element.classList.add('queue-item--processing'); + } + } + async handleCancelDownload(e) { const btn = e.target.closest('button'); btn.style.display = 'none'; @@ -358,14 +499,61 @@ class DownloadQueue { }); document.getElementById('queueTotalCount').textContent = entries.length; + + // Only recreate the container content if really needed const visibleEntries = entries.slice(0, this.visibleCount); - container.innerHTML = ''; - visibleEntries.forEach(entry => { - container.appendChild(entry.element); - if (!entry.intervalId) { - this.startEntryMonitoring(entry.uniqueId); + + // Handle empty state + if (entries.length === 0) { + container.innerHTML = ` +
+ Empty queue +

Your download queue is empty

+
+ `; + } else { + // Get currently visible items + const visibleItems = Array.from(container.children).filter(el => el.classList.contains('queue-item')); + + // Update container more efficiently + if (visibleItems.length === 0) { + // No items in container, append all visible entries + container.innerHTML = ''; // Clear any empty state + visibleEntries.forEach(entry => { + // Start monitoring if needed + if (!entry.intervalId) { + this.startEntryMonitoring(entry.uniqueId); + } + container.appendChild(entry.element); + }); + } else { + // Container already has items, update more efficiently + + // Create a map of current DOM elements by queue ID + const existingElementMap = {}; + visibleItems.forEach(el => { + const queueId = el.querySelector('.cancel-btn')?.dataset.queueid; + if (queueId) existingElementMap[queueId] = el; + }); + + // Clear container to re-add in correct order + container.innerHTML = ''; + + // Add visible entries in correct order + visibleEntries.forEach(entry => { + // Start monitoring if needed + if (!entry.intervalId) { + this.startEntryMonitoring(entry.uniqueId); + } + container.appendChild(entry.element); + + // Mark the entry as not new anymore + entry.isNew = false; + }); } - }); + } + + // Stop monitoring entries that are no longer visible entries.slice(this.visibleCount).forEach(entry => { if (entry.intervalId) { clearInterval(entry.intervalId); @@ -373,6 +561,7 @@ class DownloadQueue { } }); + // Update footer footer.innerHTML = ''; if (entries.length > this.visibleCount) { const remaining = entries.length - this.visibleCount; @@ -576,6 +765,14 @@ class DownloadQueue { clearInterval(entry.intervalId); const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`); if (!logElement) return; + + // Save the terminal state to the cache for persistence across reloads + this.queueCache[entry.prgFile] = progress; + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + + // Add status classes without triggering animations + this.applyStatusClasses(entry, progress); + if (progress.status === 'error') { const cancelBtn = entry.element.querySelector('.cancel-btn'); if (cancelBtn) { @@ -655,8 +852,6 @@ class DownloadQueue { if (cancelBtn) { cancelBtn.style.display = 'none'; } - // Add success styling - entry.element.classList.add('download-success'); setTimeout(() => this.cleanupEntry(queueId), 5000); } else { logElement.textContent = this.getStatusMessage(progress); @@ -910,12 +1105,27 @@ class DownloadQueue { const queueId = this.generateQueueId(); const entry = this.createQueueEntry(dummyItem, dummyItem.type, prgFile, queueId, requestUrl); entry.retryCount = retryCount; + + // Set the entry's last status from the PRG file + if (prgData.last_line) { + entry.lastStatus = prgData.last_line; + + // Make sure to save the status to the cache for persistence + this.queueCache[prgFile] = prgData.last_line; + + // Apply proper status classes + this.applyStatusClasses(entry, prgData.last_line); + } + this.downloadQueue[queueId] = entry; } catch (error) { console.error("Error fetching details for", prgFile, error); } } + // Save updated cache to localStorage + localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache)); + // After adding all entries, update the queue this.updateQueueOrder(); } catch (error) { diff --git a/templates/album.html b/templates/album.html index ed40370..0747fb5 100644 --- a/templates/album.html +++ b/templates/album.html @@ -3,35 +3,63 @@ - Album Viewer + Album Viewer - Spotizerr + + -
-