From 868b13af3df925a74f08b6754a59ef42e7610113 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 18:45:35 +0300 Subject: [PATCH] feat: Custom user playlists with submission and admin review - Add user playlist creation, editing, and track management - Add library browser for adding tracks to playlists - Add playlist submission workflow for station airing - Add admin review interface with preview, approve, reject - Generate M3U files on approval in playlists/user-submissions/ - Include user-submissions in playlist scheduler dropdown - Use playlist description as PHASE tag in M3U - Add database migration for user_playlists table - Update TODO-next-features.org to mark feature complete --- TODO-next-features.org | 12 +- asteroid.asd | 1 + database.lisp | 12 + frontend-partials.lisp | 17 +- migrations/008-user-playlists.sql | 30 ++ parenscript/admin.lisp | 222 +++++++++ parenscript/front-page.lisp | 61 ++- parenscript/profile.lisp | 434 +++++++++++++++++- parenscript/stream-player.lisp | 67 ++- playlist-scheduler.lisp | 18 +- .../user-submissions/admin-glenneth-1.m3u | 25 + static/asteroid.css | 426 +++++++++++++++++ static/asteroid.lass | 362 +++++++++++++++ template/admin.ctml | 25 + template/audio-player-frame.ctml | 1 + template/front-page-content.ctml | 18 + template/front-page.ctml | 4 +- template/partial/now-playing.ctml | 2 + template/profile.ctml | 91 ++++ track-requests.lisp | 27 ++ user-management.lisp | 4 + user-playlists.lisp | 421 +++++++++++++++++ user-profile.lisp | 15 + 23 files changed, 2260 insertions(+), 35 deletions(-) create mode 100644 migrations/008-user-playlists.sql create mode 100644 playlists/user-submissions/admin-glenneth-1.m3u create mode 100644 user-playlists.lisp diff --git a/TODO-next-features.org b/TODO-next-features.org index e3d59f5..8eeb9bb 100644 --- a/TODO-next-features.org +++ b/TODO-next-features.org @@ -33,15 +33,15 @@ 2) [ ] Make calendar editable, reschedule, ammend &c 3) [ ] Add bumpers to landing page for scheduled programs -4) [0/8] User Profile pages - 1) [ ] avatars +4) [5/8] User Profile pages + 1) [X] avatars 2) [ ] default playlist - 3) [ ] tarted up 'now playing' with highlights of previously upvoted tracks + 3) [X] tarted up 'now playing' with highlights of previously upvoted tracks 4) [ ] polls - 5) [ ] Listener requests interface + 5) [X] Listener requests interface 6) [ ] Calendar of upcoming scheduled 'shows' - 7) [ ] requests - 8) [ ] Custom user playlists, with submission for station airing + 7) [X] requests + 8) [X] Custom user playlists, with submission for station airing 5) [0/2] Shuffle/Random queue 1) [ ] randomly run the whole library diff --git a/asteroid.asd b/asteroid.asd index f09308b..7cae886 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -64,6 +64,7 @@ (:file "playlist-scheduler") (:file "listener-stats") (:file "user-profile") + (:file "user-playlists") (:file "track-requests") (:file "auth-routes") (:file "frontend-partials") diff --git a/database.lisp b/database.lisp index 5d1b924..7add19b 100644 --- a/database.lisp +++ b/database.lisp @@ -64,6 +64,18 @@ (listen-duration :integer) (completed :integer)))) + (unless (db:collection-exists-p "user_playlists") + (db:create "user_playlists" '((user-id :integer) + (name :text) + (description :text) + (track-ids :text) + (status :text) + (created-date :integer) + (submitted-date :integer) + (reviewed-date :integer) + (reviewed-by :integer) + (review-notes :text)))) + ;; TODO: the radiance db interface is too basic to contain anything ;; but strings, integers, booleans, and maybe timestamps... we will ;; need to rethink this. currently track/playlist relationships are diff --git a/frontend-partials.lisp b/frontend-partials.lisp index 5797731..d7ebdb1 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -88,14 +88,16 @@ (icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3"))) (now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (if now-playing-stats - (progn + (let* ((title (cdr (assoc :title now-playing-stats))) + (favorite-count (or (get-track-favorite-count title) 0))) ;; TODO: it should be able to define a custom api-output for this ;; (api-output :format "html")) (setf (header "Content-Type") "text/html") (clip:process-to-string (load-template "partial/now-playing") :stats now-playing-stats - :track-id (cdr (assoc :track-id now-playing-stats)))) + :track-id (cdr (assoc :track-id now-playing-stats)) + :favorite-count favorite-count)) (progn (setf (header "Content-Type") "text/html") (clip:process-to-string @@ -124,10 +126,13 @@ (let* ((mount-name (or mount "asteroid.mp3")) (now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (if now-playing-stats - (api-output `(("status" . "success") - ("title" . ,(cdr (assoc :title now-playing-stats))) - ("listeners" . ,(cdr (assoc :listeners now-playing-stats))) - ("track_id" . ,(cdr (assoc :track-id now-playing-stats))))) + (let* ((title (cdr (assoc :title now-playing-stats))) + (favorite-count (or (get-track-favorite-count title) 0))) + (api-output `(("status" . "success") + ("title" . ,title) + ("listeners" . ,(cdr (assoc :listeners now-playing-stats))) + ("track_id" . ,(cdr (assoc :track-id now-playing-stats))) + ("favorite_count" . ,favorite-count)))) (api-output `(("status" . "offline") ("title" . "Stream Offline") ("track_id" . nil))))))) diff --git a/migrations/008-user-playlists.sql b/migrations/008-user-playlists.sql new file mode 100644 index 0000000..76c2647 --- /dev/null +++ b/migrations/008-user-playlists.sql @@ -0,0 +1,30 @@ +-- Migration 008: User Playlists +-- Adds table for user-created playlists with submission/review workflow + +CREATE TABLE IF NOT EXISTS user_playlists ( + _id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + track_ids TEXT DEFAULT '[]', -- JSON array of track IDs + status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'scheduled')), + created_date INTEGER DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER, + submitted_date INTEGER, + reviewed_date INTEGER, + reviewed_by INTEGER REFERENCES "USERS"(_id), + review_notes TEXT +); + +-- Create indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_user_playlists_user_id ON user_playlists(user_id); +CREATE INDEX IF NOT EXISTS idx_user_playlists_status ON user_playlists(status); + +-- Grant permissions +GRANT ALL PRIVILEGES ON user_playlists TO asteroid; +GRANT ALL PRIVILEGES ON SEQUENCE user_playlists__id_seq TO asteroid; + +-- Verification +DO $$ +BEGIN + RAISE NOTICE 'Migration 008: User playlists table created successfully!'; +END $$; diff --git a/parenscript/admin.lisp b/parenscript/admin.lisp index 0c30f89..bbf5ab5 100644 --- a/parenscript/admin.lisp +++ b/parenscript/admin.lisp @@ -29,6 +29,7 @@ (refresh-liquidsoap-status) (setup-stats-refresh) (refresh-scheduler-status) + (refresh-track-requests) ;; Update Liquidsoap status every 10 seconds (set-interval refresh-liquidsoap-status 10000) ;; Update scheduler status every 30 seconds @@ -1286,6 +1287,216 @@ (ps:chain console (error "Error loading scheduled playlist:" error)) (alert "Error loading scheduled playlist"))))) + ;; ======================================== + ;; Track Requests Management + ;; ======================================== + + (defvar *current-request-tab* "pending") + + (defun format-request-time (timestamp) + "Format a timestamp for display" + (if (not timestamp) + "" + (let* ((ts-str (+ "" timestamp)) + (iso-str (if (ps:chain ts-str (includes " ")) + (+ (ps:chain ts-str (replace " " "T")) "Z") + ts-str)) + (date (ps:new (-date iso-str)))) + (if (ps:chain -number (is-na-n (ps:chain date (get-time)))) + "Recently" + (ps:chain date (to-locale-string)))))) + + (defun show-request-tab (tab) + (setf *current-request-tab* tab) + ;; Update tab button styles + (let ((tabs (ps:chain document (query-selector-all ".btn-tab")))) + (ps:chain tabs (for-each (lambda (btn) + (ps:chain btn class-list (remove "active")))))) + (let ((active-tab (ps:chain document (get-element-by-id (+ "tab-" tab))))) + (when active-tab + (ps:chain active-tab class-list (add "active")))) + ;; Load the appropriate requests + (refresh-track-requests)) + + (defun refresh-track-requests () + (let ((container (ps:chain document (get-element-by-id "pending-requests-container"))) + (status-el (ps:chain document (get-element-by-id "requests-status"))) + (url (+ "/api/asteroid/admin/requests/list?status=" *current-request-tab*))) + (when status-el + (setf (ps:@ status-el text-content) "Loading...")) + (ps:chain + (fetch url) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (when status-el + (setf (ps:@ status-el text-content) "")) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data requests) + (> (ps:@ data requests length) 0)) + (let ((html "")) + (ps:chain (ps:@ data requests) (for-each (lambda (req) + (let ((actions-html + (cond + ((= *current-request-tab* "pending") + (+ "" + "")) + ((= *current-request-tab* "approved") + "✓ Approved") + ((= *current-request-tab* "rejected") + "✗ Rejected") + ((= *current-request-tab* "played") + "🎵 Played") + (t "")))) + (setf html (+ html + "
" + "
" + "" (ps:@ req title) "" + "Requested by @" (ps:@ req username) "" + (if (ps:@ req message) + (+ "

\"" (ps:@ req message) "\"

") + "") + "" (format-request-time (ps:@ req created_at)) "" + "
" + "
" + actions-html + "
" + "
")))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) (+ "

No " *current-request-tab* " requests

"))))))) + (catch (lambda (error) + (ps:chain console (error "Error loading requests:" error)) + (when status-el + (setf (ps:@ status-el text-content) "Error loading requests"))))))) + + (defun approve-request (request-id) + (ps:chain + (fetch (+ "/api/asteroid/requests/approve?id=" request-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "✓ Request approved") + (refresh-track-requests)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error approving request:" error)) + (alert "Error approving request"))))) + + (defun reject-request (request-id) + (when (confirm "Are you sure you want to reject this request?") + (ps:chain + (fetch (+ "/api/asteroid/requests/reject?id=" request-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "Request rejected") + (refresh-track-requests)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error rejecting request:" error)) + (alert "Error rejecting request")))))) + + ;; ======================================== + ;; User Playlist Review Functions + ;; ======================================== + + (defun load-user-playlist-submissions () + (ps:chain + (fetch "/api/asteroid/admin/user-playlists") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((container (ps:chain document (get-element-by-id "user-playlists-container"))) + (data (or (ps:@ result data) result))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data playlists) + (> (ps:@ data playlists length) 0)) + (let ((html "")) + (ps:chain (ps:@ data playlists) (for-each (lambda (pl) + (let* ((ts (aref pl "submittedDate")) + (submitted-date (if ts + (ps:chain (ps:new (*Date (* ts 1000))) (to-locale-string)) + "N/A"))) + (setf html (+ html + "" + "" + "" + "" + "" + "" + "")))))) + (setf html (+ html "
PlaylistUserTracksSubmittedActions
" (aref pl "name") "" + (if (aref pl "description") (+ "
" (aref pl "description") "") "") + "
" (or (aref pl "username") "Unknown") "" (or (aref pl "trackCount") 0) " tracks" submitted-date "" + " " + " " + "" + "
")) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No playlists awaiting review

")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading user playlists:" error)) + (let ((container (ps:chain document (get-element-by-id "user-playlists-container")))) + (when container + (setf (ps:@ container inner-h-t-m-l) "

Error loading submissions

"))))))) + + (defun approve-playlist (playlist-id) + (when (confirm "Approve this playlist? It will be available for scheduling.") + (ps:chain + (fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id "&action=approve") + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (alert "Playlist approved!") + (load-user-playlist-submissions)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error approving playlist:" error)) + (alert "Error approving playlist")))))) + + (defun reject-playlist (playlist-id) + (let ((notes (prompt "Reason for rejection (optional):"))) + (ps:chain + (fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id + "&action=reject¬es=" (encode-u-r-i-component (or notes ""))) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (alert "Playlist rejected.") + (load-user-playlist-submissions)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error rejecting playlist:" error)) + (alert "Error rejecting playlist")))))) + + (defun preview-playlist (playlist-id) + (ps:chain + (fetch (+ "/api/asteroid/admin/user-playlists/preview?id=" playlist-id)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (let ((m3u (aref data "m3u"))) + ;; Show in a modal or alert + (alert (+ "Playlist M3U Preview:\n\n" m3u))) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error previewing playlist:" error)) + (alert "Error previewing playlist"))))) + ;; Make functions globally accessible for onclick handlers (setf (ps:@ window go-to-page) go-to-page) (setf (ps:@ window previous-page) previous-page) @@ -1309,6 +1520,17 @@ (setf (ps:@ window load-current-scheduled-playlist) load-current-scheduled-playlist) (setf (ps:@ window add-schedule-entry) add-schedule-entry) (setf (ps:@ window remove-schedule-entry) remove-schedule-entry) + (setf (ps:@ window refresh-track-requests) refresh-track-requests) + (setf (ps:@ window approve-request) approve-request) + (setf (ps:@ window reject-request) reject-request) + (setf (ps:@ window show-request-tab) show-request-tab) + (setf (ps:@ window load-user-playlist-submissions) load-user-playlist-submissions) + (setf (ps:@ window approve-playlist) approve-playlist) + (setf (ps:@ window reject-playlist) reject-playlist) + (setf (ps:@ window preview-playlist) preview-playlist) + + ;; Load user playlist submissions on page load + (load-user-playlist-submissions) )) "Compiled JavaScript for admin dashboard - generated at load time") diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index cadbff4..3477a7e 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -168,6 +168,39 @@ ;; Track last recorded title to avoid duplicate history entries (defvar *last-recorded-title-main* nil) + ;; Cache of user's favorite track titles for quick lookup + (defvar *user-favorites-cache* (array)) + + ;; Load user's favorites into cache + (defun load-favorites-cache () + (ps:chain + (fetch "/api/asteroid/user/favorites") + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + nil))) + (then (lambda (data) + (when (and data (ps:@ data data) (ps:@ data data favorites)) + (setf *user-favorites-cache* + (ps:chain (ps:@ data data favorites) + (map (lambda (f) (ps:@ f title)))))))) + (catch (lambda (error) nil)))) + + ;; Check if current track is in favorites and update UI + (defun check-favorite-status () + (let ((title-el (ps:chain document (get-element-by-id "current-track-title"))) + (btn (ps:chain document (get-element-by-id "favorite-btn")))) + (when (and title-el btn) + (let ((title (ps:@ title-el text-content)) + (star-icon (ps:chain btn (query-selector ".star-icon")))) + (if (ps:chain *user-favorites-cache* (includes title)) + (progn + (ps:chain btn class-list (add "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "★"))) + (progn + (ps:chain btn class-list (remove "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "☆")))))))) + ;; Record track to listening history (only if logged in) (defun record-track-listen-main (title) (when (and title (not (= title "")) (not (= title "Loading...")) @@ -177,8 +210,7 @@ (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) (ps:create :method "POST")) (then (lambda (response) - (when (ps:@ response ok) - (ps:chain console (log "Recorded listen:" title))))) + (ps:@ response ok))) (catch (lambda (error) ;; Silently fail - user might not be logged in nil))))) @@ -206,7 +238,20 @@ ;; Record if title changed (when (or (not old-title-el) (not (= (ps:@ old-title-el text-content) new-title))) - (record-track-listen-main new-title)))))))))) + (record-track-listen-main new-title)) + ;; Check if this track is in user's favorites + (check-favorite-status) + ;; Update favorite count display + (let ((count-el (ps:chain document (get-element-by-id "favorite-count-display"))) + (count-val-el (ps:chain document (get-element-by-id "favorite-count-value")))) + (when (and count-el count-val-el) + (let ((fav-count (parse-int (or (ps:@ count-val-el value) "0") 10))) + (if (> fav-count 0) + (setf (ps:@ count-el text-content) + (if (= fav-count 1) + "1 person loves this track ❤️" + (+ fav-count " people love this track ❤️"))) + (setf (ps:@ count-el text-content) ""))))))))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch stream status:" error))))))) @@ -582,6 +627,9 @@ (when is-frameset-page (set-interval update-stream-information 10000))) + ;; Load user's favorites for highlight feature + (load-favorites-cache) + ;; Update now playing (update-now-playing) @@ -650,7 +698,9 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (remove "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆") + ;; Refresh now playing to update favorite count + (update-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error removing favorite:" error))))) ;; Add favorite @@ -667,7 +717,8 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (add "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★") + (update-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error adding favorite:" error))))))))))) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index 70d69dd..4c6af2e 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -327,7 +327,46 @@ (load-favorites) (load-top-artists) (load-activity-chart) - (load-avatar)) + (load-avatar) + (load-my-requests)) + + ;; Load user's track requests + (defun load-my-requests () + (ps:chain + (fetch "/api/asteroid/requests/my") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result)) + (container (ps:chain document (get-element-by-id "my-requests-list")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data requests) + (> (ps:@ data requests length) 0)) + (let ((html "")) + (ps:chain (ps:@ data requests) (for-each (lambda (req) + (let ((status-class (cond + ((= (ps:@ req status) "pending") "status-pending") + ((= (ps:@ req status) "approved") "status-approved") + ((= (ps:@ req status) "rejected") "status-rejected") + ((= (ps:@ req status) "played") "status-played") + (t ""))) + (status-icon (cond + ((= (ps:@ req status) "pending") "⏳") + ((= (ps:@ req status) "approved") "✓") + ((= (ps:@ req status) "rejected") "✗") + ((= (ps:@ req status) "played") "🎵") + (t "?")))) + (setf html (+ html + "
" + "
" (ps:@ req title) "
" + "
" + "" status-icon " " (ps:@ req status) "" + "
" + "
")))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

You haven't made any requests yet.

")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading requests:" error)))))) ;; Action functions (defun edit-profile () @@ -426,11 +465,402 @@ false)) + ;; ======================================== + ;; User Playlists functionality + ;; ======================================== + + (defvar *library-page* 1) + (defvar *library-search* "") + (defvar *library-artist* "") + (defvar *library-total* 0) + (defvar *current-playlist-tracks* (array)) + (defvar *user-playlists* (array)) + + ;; Load user's playlists + (defun load-my-playlists () + (ps:chain + (fetch "/api/asteroid/user/playlists") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result)) + (container (ps:chain document (get-element-by-id "my-playlists-list")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data playlists) + (> (ps:@ data playlists length) 0)) + (progn + (setf *user-playlists* (ps:@ data playlists)) + (let ((html "")) + (ps:chain (ps:@ data playlists) (for-each (lambda (pl) + (let ((playlist-id (or (ps:@ pl id) (aref pl "id"))) + (status-class (cond + ((= (ps:@ pl status) "draft") "status-draft") + ((= (ps:@ pl status) "submitted") "status-pending") + ((= (ps:@ pl status) "approved") "status-approved") + ((= (ps:@ pl status) "rejected") "status-rejected") + (t ""))) + (status-icon (cond + ((= (ps:@ pl status) "draft") "📝") + ((= (ps:@ pl status) "submitted") "⏳") + ((= (ps:@ pl status) "approved") "✓") + ((= (ps:@ pl status) "rejected") "✗") + (t "?")))) + (ps:chain console (log "Playlist:" pl "ID:" playlist-id)) + (setf html (+ html + "
" + "
" + "" (or (ps:@ pl name) (aref pl "name")) "" + "" (or (ps:@ pl track-count) (aref pl "track-count") 0) " tracks" + "
" + "
" + "" status-icon " " (or (ps:@ pl status) (aref pl "status")) "" + (if (= (or (ps:@ pl status) (aref pl "status")) "draft") + (+ "") + "") + "
" + "
")))))) + (setf (ps:@ container inner-h-t-m-l) html))) + (setf (ps:@ container inner-h-t-m-l) "

No playlists yet. Create one to get started!

")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading playlists:" error)))))) + + ;; Modal functions + (defun show-create-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "create-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "flex")))) + + (defun hide-create-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "create-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "none") + (ps:chain (ps:chain document (get-element-by-id "create-playlist-form")) (reset))))) + + (defun show-library-browser () + (let ((modal (ps:chain document (get-element-by-id "library-browser-modal")))) + (when modal + (setf (ps:@ modal style display) "flex") + (load-library-tracks) + (update-playlist-select)))) + + (defun hide-library-browser () + (let ((modal (ps:chain document (get-element-by-id "library-browser-modal")))) + (when modal + (setf (ps:@ modal style display) "none")))) + + (defun show-library-browser-for-playlist () + (show-library-browser)) + + (defun show-edit-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "flex")))) + + (defun hide-edit-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "none")))) + + ;; Create playlist + (defun create-playlist (event) + (ps:chain event (prevent-default)) + (let ((name (ps:@ (ps:chain document (get-element-by-id "playlist-name")) value)) + (description (ps:@ (ps:chain document (get-element-by-id "playlist-description")) value)) + (message-div (ps:chain document (get-element-by-id "create-playlist-message")))) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/create?name=" (encode-u-r-i-component name) + "&description=" (encode-u-r-i-component description)) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist created!" "success") + (hide-create-playlist-modal) + (load-my-playlists) + ;; Open the new playlist for editing + (when (ps:@ data playlist id) + (edit-playlist (ps:@ data playlist id)))) + (progn + (setf (ps:@ message-div text-content) (or (ps:@ data message) "Failed to create playlist")) + (setf (ps:@ message-div class-name) "message error")))))) + (catch (lambda (error) + (ps:chain console (error "Error creating playlist:" error)) + (setf (ps:@ message-div text-content) "Error creating playlist") + (setf (ps:@ message-div class-name) "message error"))))) + false) + + ;; Edit playlist + (defun edit-playlist (playlist-id) + (ps:chain console (log "edit-playlist called with id:" playlist-id)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/get?id=" playlist-id)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (ps:chain console (log "edit-playlist response:" result)) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (let* ((pl (ps:@ data playlist)) + (pl-id (or (ps:@ pl id) (aref pl "id"))) + (pl-name (or (ps:@ pl name) (aref pl "name"))) + (pl-desc (or (ps:@ pl description) (aref pl "description") "")) + (pl-tracks (or (ps:@ pl tracks) (aref pl "tracks") (array)))) + (ps:chain console (log "Playlist id:" pl-id "name:" pl-name)) + (setf (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value) pl-id) + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value) pl-name) + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value) pl-desc) + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " pl-name)) + (setf *current-playlist-tracks* pl-tracks) + (render-playlist-tracks) + (show-edit-playlist-modal)) + (show-message "Failed to load playlist" "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error loading playlist:" error)) + (show-message "Error loading playlist" "error"))))) + + (defun render-playlist-tracks () + (let ((container (ps:chain document (get-element-by-id "playlist-tracks-list")))) + (when container + (if (> (ps:@ *current-playlist-tracks* length) 0) + (let ((html "")) + (ps:chain *current-playlist-tracks* (for-each (lambda (track index) + (setf html (+ html + "
" + "" (+ index 1) "." + "" (ps:@ track title) "" + "" (ps:@ track artist) "" + "
" + "" + "" + "" + "
" + "
"))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No tracks yet. Browse the library to add tracks!

"))))) + + (defun move-track-in-playlist (index direction) + (let ((new-index (+ index direction))) + (when (and (>= new-index 0) (< new-index (ps:@ *current-playlist-tracks* length))) + (let ((track (ps:chain *current-playlist-tracks* (splice index 1)))) + (ps:chain *current-playlist-tracks* (splice new-index 0 (ps:getprop track 0))) + (render-playlist-tracks) + (save-playlist-tracks))))) + + (defun remove-track-from-playlist (index) + (ps:chain *current-playlist-tracks* (splice index 1)) + (render-playlist-tracks) + (save-playlist-tracks)) + + (defun add-track-to-playlist (track-id title artist album) + (ps:chain console (log "addTrackToPlaylist called with track-id:" track-id "title:" title)) + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when (not playlist-id) + ;; No playlist open, use the select dropdown + (let ((select (ps:chain document (get-element-by-id "add-to-playlist-select")))) + (when select + (setf playlist-id (ps:@ select value))))) + (when (not playlist-id) + (show-message "Please select a playlist first" "warning") + (return)) + ;; Add to current tracks array + (ps:chain console (log "Adding track with id:" track-id "to playlist:" playlist-id)) + ;; Create object and set id property explicitly + (let ((track-obj (ps:create))) + (setf (ps:@ track-obj id) track-id) + (setf (ps:@ track-obj title) title) + (setf (ps:@ track-obj artist) artist) + (setf (ps:@ track-obj album) album) + (ps:chain *current-playlist-tracks* (push track-obj))) + (ps:chain console (log "Current tracks:" *current-playlist-tracks*)) + (render-playlist-tracks) + (save-playlist-tracks) + (show-message (+ "Added: " title) "success"))) + + (defun save-playlist-tracks () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when playlist-id + ;; Access id property directly - use 'trk' not 't' (t is boolean true in Lisp/ParenScript) + (let ((track-ids (ps:chain *current-playlist-tracks* (map (lambda (trk) (ps:@ trk id)))))) + (ps:chain console (log "Saving track-ids:" track-ids)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id + "&tracks=" (encode-u-r-i-component (ps:chain -j-s-o-n (stringify track-ids)))) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (catch (lambda (error) + (ps:chain console (error "Error saving playlist:" error))))))))) + + (defun save-playlist-metadata () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)) + (name (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value)) + (description (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value))) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id + "&name=" (encode-u-r-i-component name) + "&description=" (encode-u-r-i-component description)) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist saved!" "success") + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " name)) + (load-my-playlists)) + (show-message "Failed to save playlist" "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error saving playlist:" error)) + (show-message "Error saving playlist" "error")))))) + + (defun submit-playlist-for-review () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when (not (confirm "Submit this playlist for admin review? You won't be able to edit it after submission.")) + (return)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/submit?id=" playlist-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist submitted for review!" "success") + (hide-edit-playlist-modal) + (load-my-playlists)) + (show-message (or (ps:@ data message) "Failed to submit playlist") "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error submitting playlist:" error)) + (show-message "Error submitting playlist" "error")))))) + + (defun delete-current-playlist () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when (not (confirm "Delete this playlist? This cannot be undone.")) + (return)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/delete?id=" playlist-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist deleted" "success") + (hide-edit-playlist-modal) + (load-my-playlists)) + (show-message "Failed to delete playlist" "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error deleting playlist:" error)) + (show-message "Error deleting playlist" "error")))))) + + ;; Library browsing + (defun load-library-tracks () + (let ((url (+ "/api/asteroid/library/browse?page=" *library-page*))) + (when (and *library-search* (> (ps:@ *library-search* length) 0)) + (setf url (+ url "&search=" (encode-u-r-i-component *library-search*)))) + (when (and *library-artist* (> (ps:@ *library-artist* length) 0)) + (setf url (+ url "&artist=" (encode-u-r-i-component *library-artist*)))) + (ps:chain + (fetch url) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result)) + (container (ps:chain document (get-element-by-id "library-tracks"))) + (artist-select (ps:chain document (get-element-by-id "library-artist-filter")))) + (when container + (setf *library-total* (or (ps:@ data total) 0)) + (if (and (= (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (let ((html "")) + (ps:chain (ps:@ data tracks) (for-each (lambda (track) + (setf html (+ html + "
" + "
" + "" (ps:@ track title) "" + "" (ps:@ track artist) "" + "" (ps:@ track album) "" + "
" + "" + "
"))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No tracks found

"))) + ;; Update artist filter + (when (and artist-select (ps:@ data artists)) + (let ((current-val (ps:@ artist-select value))) + (setf (ps:@ artist-select inner-h-t-m-l) "") + (ps:chain (ps:@ data artists) (for-each (lambda (artist) + (let ((opt (ps:chain document (create-element "option")))) + (setf (ps:@ opt value) artist) + (setf (ps:@ opt text-content) artist) + (ps:chain artist-select (append-child opt)))))) + (setf (ps:@ artist-select value) current-val))) + ;; Update pagination + (update-library-pagination)))) + (catch (lambda (error) + (ps:chain console (error "Error loading library:" error))))))) + + (defun update-library-pagination () + (let ((page-info (ps:chain document (get-element-by-id "library-page-info"))) + (prev-btn (ps:chain document (get-element-by-id "lib-prev-btn"))) + (next-btn (ps:chain document (get-element-by-id "lib-next-btn"))) + (total-pages (ps:chain -math (ceil (/ *library-total* 50))))) + (when page-info + (setf (ps:@ page-info text-content) (+ "Page " *library-page* " of " total-pages))) + (when prev-btn + (setf (ps:@ prev-btn disabled) (<= *library-page* 1))) + (when next-btn + (setf (ps:@ next-btn disabled) (>= *library-page* total-pages))))) + + (defun prev-library-page () + (when (> *library-page* 1) + (setf *library-page* (- *library-page* 1)) + (load-library-tracks))) + + (defun next-library-page () + (setf *library-page* (+ *library-page* 1)) + (load-library-tracks)) + + (defvar *search-timeout* nil) + + (defun search-library () + (when *search-timeout* + (clear-timeout *search-timeout*)) + (setf *search-timeout* + (set-timeout + (lambda () + (setf *library-search* (ps:@ (ps:chain document (get-element-by-id "library-search")) value)) + (setf *library-page* 1) + (load-library-tracks)) + 300))) + + (defun filter-by-artist () + (setf *library-artist* (ps:@ (ps:chain document (get-element-by-id "library-artist-filter")) value)) + (setf *library-page* 1) + (load-library-tracks)) + + (defun update-playlist-select () + (let ((select (ps:chain document (get-element-by-id "add-to-playlist-select")))) + (when select + (setf (ps:@ select inner-h-t-m-l) "") + (ps:chain *user-playlists* (for-each (lambda (pl) + (when (= (ps:@ pl status) "draft") + (let ((opt (ps:chain document (create-element "option")))) + (setf (ps:@ opt value) (ps:@ pl id)) + (setf (ps:@ opt text-content) (ps:@ pl name)) + (ps:chain select (append-child opt)))))))))) + ;; Initialize on page load (ps:chain window (add-event-listener "DOMContentLoaded" - load-profile-data)))) + (lambda () + (load-profile-data) + (load-my-playlists)))))) "Compiled JavaScript for profile page - generated at load time") (defun generate-profile-js () diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index 7d83537..cad3fb2 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -201,6 +201,39 @@ ;; Track the last recorded title to avoid duplicate history entries (defvar *last-recorded-title* nil) + ;; Cache of user's favorite track titles for quick lookup (mini player) + (defvar *user-favorites-cache-mini* (array)) + + ;; Load user's favorites into cache (mini player) + (defun load-favorites-cache-mini () + (ps:chain + (fetch "/api/asteroid/user/favorites") + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + nil))) + (then (lambda (data) + (when (and data (ps:@ data data) (ps:@ data data favorites)) + (setf *user-favorites-cache-mini* + (ps:chain (ps:@ data data favorites) + (map (lambda (f) (ps:@ f title)))))))) + (catch (lambda (error) nil)))) + + ;; Check if current track is in favorites and update mini player UI + (defun check-favorite-status-mini () + (let ((title-el (ps:chain document (get-element-by-id "mini-now-playing"))) + (btn (ps:chain document (get-element-by-id "favorite-btn-mini")))) + (when (and title-el btn) + (let ((title (ps:@ title-el text-content)) + (star-icon (ps:chain btn (query-selector ".star-icon")))) + (if (ps:chain *user-favorites-cache-mini* (includes title)) + (progn + (ps:chain btn class-list (add "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "★"))) + (progn + (ps:chain btn class-list (remove "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "☆")))))))) + ;; Record track to listening history (only if logged in) (defun record-track-listen (title) (when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*))) @@ -209,11 +242,8 @@ (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) (ps:create :method "POST")) (then (lambda (response) - (when (ps:@ response ok) - (ps:chain console (log "Recorded listen:" title))))) - (catch (lambda (error) - ;; Silently fail - user might not be logged in - nil))))) + (ps:@ response ok))) + (catch (lambda (error) nil))))) ;; Update mini now playing display (for persistent player frame) (defun update-mini-now-playing () @@ -233,10 +263,20 @@ ;; Check if track changed and record to history (when (not (= (ps:@ el text-content) title)) (record-track-listen title)) - (setf (ps:@ el text-content) title)) + (setf (ps:@ el text-content) title) + ;; Check if this track is in user's favorites + (check-favorite-status-mini)) (when track-id-el (let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id)))) - (setf (ps:@ track-id-el value) (or track-id "")))))))) + (setf (ps:@ track-id-el value) (or track-id "")))) + ;; Update favorite count display + (let ((count-el (ps:chain document (get-element-by-id "favorite-count-mini"))) + (fav-count (or (ps:@ data data favorite_count) (ps:@ data favorite_count) 0))) + (when count-el + (cond + ((= fav-count 0) (setf (ps:@ count-el text-content) "")) + ((= fav-count 1) (setf (ps:@ count-el text-content) "1 ❤️")) + (t (setf (ps:@ count-el text-content) (+ fav-count " ❤️")))))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch now playing:" error))))))) @@ -269,7 +309,10 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (remove "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆") + ;; Reload cache and refresh display to update favorite count + (load-favorites-cache-mini) + (update-mini-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error removing favorite:" error))))) ;; Add favorite @@ -286,7 +329,10 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (add "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★") + ;; Reload cache and refresh display to update favorite count + (load-favorites-cache-mini) + (update-mini-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error adding favorite:" error))))))))))) @@ -604,6 +650,9 @@ (defun init-persistent-player () (let ((audio-element (ps:chain document (get-element-by-id "persistent-audio")))) (when audio-element + ;; Load user's favorites for highlight feature + (load-favorites-cache-mini) + ;; Try to enable low-latency mode if supported (when (ps:@ navigator media-session) (setf (ps:@ navigator media-session metadata) diff --git a/playlist-scheduler.lisp b/playlist-scheduler.lisp index 392d2cf..0abb893 100644 --- a/playlist-scheduler.lisp +++ b/playlist-scheduler.lisp @@ -163,11 +163,19 @@ (sort (copy-list *playlist-schedule*) #'< :key #'car)) (defun get-available-playlists () - "Get list of available playlist files from the playlists directory." - (let ((playlists-dir (get-playlists-directory))) - (when (probe-file playlists-dir) - (mapcar #'file-namestring - (directory (merge-pathnames "*.m3u" playlists-dir)))))) + "Get list of available playlist files from the playlists directory and user-submissions." + (let ((playlists-dir (get-playlists-directory)) + (submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory)))) + (append + ;; Main playlists directory + (when (probe-file playlists-dir) + (mapcar #'file-namestring + (directory (merge-pathnames "*.m3u" playlists-dir)))) + ;; User submissions directory (prefixed with user-submissions/) + (when (probe-file submissions-dir) + (mapcar (lambda (path) + (format nil "user-submissions/~a" (file-namestring path))) + (directory (merge-pathnames "*.m3u" submissions-dir))))))) (defun get-server-time-info () "Get current server time information in both UTC and local timezone." diff --git a/playlists/user-submissions/admin-glenneth-1.m3u b/playlists/user-submissions/admin-glenneth-1.m3u new file mode 100644 index 0000000..09861fc --- /dev/null +++ b/playlists/user-submissions/admin-glenneth-1.m3u @@ -0,0 +1,25 @@ +#EXTM3U +#PLAYLIST:glenneth +#PHASE:Zero Gravity +#CURATOR:admin + +#EXTINF:-1,Kiasmos - 65 +/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/01. Kiasmos - 65.flac +#EXTINF:-1,Kiasmos - Walled +/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/02. Kiasmos - Walled.flac +#EXTINF:-1,Kiasmos - Bound +/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 05 Bound.flac +#EXTINF:-1,Kiasmos - Burst +/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 02 Burst.flac +#EXTINF:-1,Kiasmos - Dazed +/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 10 Dazed.flac +#EXTINF:-1,Kiasmos - Held +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/02 - Held.flac +#EXTINF:-1,Kiasmos - Dragged +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/06 - Dragged.flac +#EXTINF:-1,Kiasmos - Lit +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/01 - Lit.flac +#EXTINF:-1,Kiasmos - Looped +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/03 - Looped.flac +#EXTINF:-1,Kiasmos - Burnt [Lubomyr Melnyl Rework] +/home/fade/Media/Music/Kiasmos/2015 - Looped/03 Burnt (Lubomyr Melnyl Rework).flac diff --git a/static/asteroid.css b/static/asteroid.css index 77b3c5a..a77f721 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1842,6 +1842,136 @@ body.popout-body .status-mini{ font-style: italic; } +.request-item-admin{ + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 15px; + margin-bottom: 10px; + background: rgba(0, 255, 0, 0.05); + border: 1px solid #333; + border-radius: 8px; +} + +.request-item-admin .request-info{ + flex: 1; +} + +.request-item-admin .request-info strong{ + color: #00cc00; + font-size: 1.1em; + display: block; + margin-bottom: 5px; +} + +.request-item-admin .request-info .request-user{ + color: #888; + font-size: 0.9em; + display: block; + margin-bottom: 5px; +} + +.request-item-admin .request-info .request-message{ + color: #aaa; + font-style: italic; + margin: 8px 0; +} + +.request-item-admin .request-info .request-time{ + color: #666; + font-size: 0.8em; +} + +.request-item-admin .request-actions{ + display: flex; + gap: 8px; + margin-left: 15px; +} + +.my-request-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + margin-bottom: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid #333; + border-radius: 6px; +} + +.my-request-item .request-title{ + color: #ddd; + flex: 1; +} + +.my-request-item .request-status{ + margin-left: 15px; +} + +.status-badge{ + padding: 4px 10px; + border-radius: 12px; + font-size: 0.85em; + text-transform: capitalize; +} + +.favorite-count{ + color: #ff6699; + font-size: 0.9em; + margin: 5px 0; +} + +.favorite-count-mini{ + color: #ff6699; + font-size: 0.85em; + margin-left: 8px; +} + +.status-pending{ + background: rgba(255, 200, 0, 0.2); + color: #ffcc00; +} + +.status-approved{ + background: rgba(0, 255, 0, 0.2); + color: #00ff00; +} + +.status-rejected{ + background: rgba(255, 0, 0, 0.2); + color: #ff6666; +} + +.status-played{ + background: rgba(0, 200, 255, 0.2); + color: #00ccff; +} + +.btn-tab{ + background: transparent; + border: 1px solid #444; + color: #888; + padding: 8px 16px; + margin-right: 5px; + cursor: pointer; + -moz-transition: all 0.2s; + -o-transition: all 0.2s; + -webkit-transition: all 0.2s; + -ms-transition: all 0.2s; + transition: all 0.2s; +} + +.btn-tab:hover{ + border-color: #00cc00; + color: #00cc00; +} + +.btn-tab.active{ + background: rgba(0, 255, 0, 0.1); + border-color: #00cc00; + color: #00cc00; +} + .activity-chart{ padding: 15px; } @@ -1911,4 +2041,300 @@ body.popout-body .status-mini{ font-style: italic; text-align: center; padding: 20px; +} + +.section-description{ + color: #888; + margin-bottom: 15px; + font-size: 0.9em; +} + +.playlist-actions{ + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.playlists-list{ + display: flex; + flex-direction: column; + gap: 10px; +} + +.playlist-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid #333; + border-radius: 4px; +} + +.playlist-info{ + display: flex; + flex-direction: column; + gap: 4px; +} + +.playlist-name{ + font-weight: bold; + color: #00cc00; +} + +.playlist-meta{ + font-size: 0.85em; + color: #888; +} + +.playlist-actions{ + display: flex; + align-items: center; + gap: 10px; +} + +.modal{ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content{ + background: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 25px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; +} + +.modal-large{ + max-width: 800px; +} + +.modal-close{ + position: absolute; + top: 10px; + right: 15px; + font-size: 24px; + color: #888; + cursor: pointer; + -moz-transition: color 0.2s; + -o-transition: color 0.2s; + -webkit-transition: color 0.2s; + -ms-transition: color 0.2s; + transition: color 0.2s; +} + +.modal-close:hover{ + color: #00cc00; +} + +.library-controls{ + display: flex; + gap: 10px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.library-controls input{ + flex: 1; + min-width: 200px; + padding: 8px 12px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; +} + +.library-controls select{ + padding: 8px 12px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; +} + +.library-tracks-list{ + max-height: 400px; + overflow-y: auto; + margin-bottom: 15px; +} + +.library-track-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border-bottom: 1px solid #333; +} + +.library-track-item:hover{ + background: rgba(0, 255, 0, 0.05); +} + +.library-track-item .track-info{ + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.library-track-item .track-title{ + color: #fff; + font-weight: bold; +} + +.library-track-item .track-artist{ + color: #00cc00; + font-size: 0.9em; +} + +.library-track-item .track-album{ + color: #666; + font-size: 0.85em; +} + +.library-pagination{ + display: flex; + justify-content: center; + align-items: center; + gap: 15px; +} + +.playlist-edit-header{ + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.playlist-edit-header input{ + padding: 10px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; + font-size: 1.1em; +} + +.playlist-edit-header textarea{ + padding: 10px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; + resize: vertical; +} + +.playlist-tracks-container{ + margin-bottom: 20px; +} + +.playlist-tracks-sortable{ + max-height: 300px; + overflow-y: auto; + border: 1px solid #333; + border-radius: 4px; + padding: 10px; +} + +.playlist-track-item{ + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + margin-bottom: 5px; +} + +.playlist-track-item .track-number{ + color: #666; + min-width: 25px; +} + +.playlist-track-item .track-title{ + flex: 1; + color: #fff; +} + +.playlist-track-item .track-artist{ + color: #00cc00; + font-size: 0.9em; +} + +.playlist-track-item .track-controls{ + display: flex; + gap: 5px; +} + +.btn-tiny{ + padding: 2px 6px; + font-size: 0.8em; + background: transparent; + border: 1px solid #444; + color: #888; + cursor: pointer; + border-radius: 3px; +} + +.btn-tiny:hover{ + border-color: #00cc00; + color: #00cc00; +} + +.btn-tiny:disabled{ + opacity: 0.3; + cursor: not-allowed; +} + +.btn-danger{ + border-color: #cc0000; + color: #cc0000; +} + +.btn-danger:hover{ + border-color: #ff0000; + color: #ff0000; + background: rgba(255, 0, 0, 0.1); +} + +.playlist-edit-actions{ + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.empty-message{ + color: #666; + font-style: italic; + text-align: center; + padding: 20px; +} + +.status-draft{ + border-left: 3px solid #888; +} + +.status-pending{ + border-left: 3px solid #ffcc00; +} + +.status-approved{ + border-left: 3px solid #00cc00; +} + +.status-rejected{ + border-left: 3px solid #cc0000; } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index 1c5b0ac..98d662a 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1475,6 +1475,116 @@ :color "#666" :font-style "italic") + ;; Admin request items + (.request-item-admin + :display "flex" + :justify-content "space-between" + :align-items "flex-start" + :padding "15px" + :margin-bottom "10px" + :background "rgba(0, 255, 0, 0.05)" + :border "1px solid #333" + :border-radius "8px" + + (.request-info + :flex "1" + + (strong + :color "#00cc00" + :font-size "1.1em" + :display "block" + :margin-bottom "5px") + + (.request-user + :color "#888" + :font-size "0.9em" + :display "block" + :margin-bottom "5px") + + (.request-message + :color "#aaa" + :font-style "italic" + :margin "8px 0") + + (.request-time + :color "#666" + :font-size "0.8em")) + + (.request-actions + :display "flex" + :gap "8px" + :margin-left "15px")) + + ;; User's request items on profile + (.my-request-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "12px 15px" + :margin-bottom "8px" + :background "rgba(255, 255, 255, 0.05)" + :border "1px solid #333" + :border-radius "6px" + + (.request-title + :color "#ddd" + :flex "1") + + (.request-status + :margin-left "15px")) + + (.status-badge + :padding "4px 10px" + :border-radius "12px" + :font-size "0.85em" + :text-transform "capitalize") + + ;; Favorite count display + (.favorite-count + :color "#ff6699" + :font-size "0.9em" + :margin "5px 0") + + (.favorite-count-mini + :color "#ff6699" + :font-size "0.85em" + :margin-left "8px") + + (.status-pending + :background "rgba(255, 200, 0, 0.2)" + :color "#ffcc00") + + (.status-approved + :background "rgba(0, 255, 0, 0.2)" + :color "#00ff00") + + (.status-rejected + :background "rgba(255, 0, 0, 0.2)" + :color "#ff6666") + + (.status-played + :background "rgba(0, 200, 255, 0.2)" + :color "#00ccff") + + ;; Tab buttons + (".btn-tab" + :background "transparent" + :border "1px solid #444" + :color "#888" + :padding "8px 16px" + :margin-right "5px" + :cursor "pointer" + :transition "all 0.2s") + + (".btn-tab:hover" + :border-color "#00cc00" + :color "#00cc00") + + (".btn-tab.active" + :background "rgba(0, 255, 0, 0.1)" + :border-color "#00cc00" + :color "#00cc00") + ;; Activity chart styling (.activity-chart :padding "15px" @@ -1532,4 +1642,256 @@ :font-style "italic" :text-align "center" :padding "20px")) + + ;; User Playlists styling + (.section-description + :color "#888" + :margin-bottom "15px" + :font-size "0.9em") + + (.playlist-actions + :display "flex" + :gap "10px" + :margin-bottom "15px") + + (.playlists-list + :display "flex" + :flex-direction "column" + :gap "10px") + + (.playlist-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "12px 15px" + :background "rgba(0, 0, 0, 0.3)" + :border "1px solid #333" + :border-radius "4px") + + (.playlist-info + :display "flex" + :flex-direction "column" + :gap "4px") + + (.playlist-name + :font-weight "bold" + :color "#00cc00") + + (.playlist-meta + :font-size "0.85em" + :color "#888") + + (".playlist-actions" + :display "flex" + :align-items "center" + :gap "10px") + + ;; Modal styling + (.modal + :position "fixed" + :top "0" + :left "0" + :width "100%" + :height "100%" + :background "rgba(0, 0, 0, 0.8)" + :display "flex" + :justify-content "center" + :align-items "center" + :z-index "1000") + + (.modal-content + :background "#1a1a1a" + :border "1px solid #333" + :border-radius "8px" + :padding "25px" + :max-width "500px" + :width "90%" + :max-height "80vh" + :overflow-y "auto" + :position "relative") + + (.modal-large + :max-width "800px") + + (.modal-close + :position "absolute" + :top "10px" + :right "15px" + :font-size "24px" + :color "#888" + :cursor "pointer" + :transition "color 0.2s") + + ((:and .modal-close :hover) + :color "#00cc00") + + ;; Library browser styling + (.library-controls + :display "flex" + :gap "10px" + :margin-bottom "15px" + :flex-wrap "wrap") + + (".library-controls input" + :flex "1" + :min-width "200px" + :padding "8px 12px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px") + + (".library-controls select" + :padding "8px 12px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px") + + (.library-tracks-list + :max-height "400px" + :overflow-y "auto" + :margin-bottom "15px") + + (.library-track-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "10px" + :border-bottom "1px solid #333") + + ((:and .library-track-item :hover) + :background "rgba(0, 255, 0, 0.05)") + + (".library-track-item .track-info" + :display "flex" + :flex-direction "column" + :gap "2px" + :flex "1") + + (".library-track-item .track-title" + :color "#fff" + :font-weight "bold") + + (".library-track-item .track-artist" + :color "#00cc00" + :font-size "0.9em") + + (".library-track-item .track-album" + :color "#666" + :font-size "0.85em") + + (.library-pagination + :display "flex" + :justify-content "center" + :align-items "center" + :gap "15px") + + ;; Playlist edit modal styling + (.playlist-edit-header + :display "flex" + :flex-direction "column" + :gap "10px" + :margin-bottom "20px") + + (".playlist-edit-header input" + :padding "10px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px" + :font-size "1.1em") + + (".playlist-edit-header textarea" + :padding "10px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px" + :resize "vertical") + + (.playlist-tracks-container + :margin-bottom "20px") + + (.playlist-tracks-sortable + :max-height "300px" + :overflow-y "auto" + :border "1px solid #333" + :border-radius "4px" + :padding "10px") + + (.playlist-track-item + :display "flex" + :align-items "center" + :gap "10px" + :padding "8px" + :background "rgba(0, 0, 0, 0.2)" + :border-radius "4px" + :margin-bottom "5px") + + (".playlist-track-item .track-number" + :color "#666" + :min-width "25px") + + (".playlist-track-item .track-title" + :flex "1" + :color "#fff") + + (".playlist-track-item .track-artist" + :color "#00cc00" + :font-size "0.9em") + + (".playlist-track-item .track-controls" + :display "flex" + :gap "5px") + + (.btn-tiny + :padding "2px 6px" + :font-size "0.8em" + :background "transparent" + :border "1px solid #444" + :color "#888" + :cursor "pointer" + :border-radius "3px") + + ((:and .btn-tiny :hover) + :border-color "#00cc00" + :color "#00cc00") + + ((:and .btn-tiny :disabled) + :opacity "0.3" + :cursor "not-allowed") + + (.btn-danger + :border-color "#cc0000" + :color "#cc0000") + + ((:and .btn-danger :hover) + :border-color "#ff0000" + :color "#ff0000" + :background "rgba(255, 0, 0, 0.1)") + + (.playlist-edit-actions + :display "flex" + :gap "10px" + :flex-wrap "wrap") + + (.empty-message + :color "#666" + :font-style "italic" + :text-align "center" + :padding "20px") + + ;; Status badges for playlists + (.status-draft + :border-left "3px solid #888") + + (.status-pending + :border-left "3px solid #ffcc00") + + (.status-approved + :border-left "3px solid #00cc00") + + (.status-rejected + :border-left "3px solid #cc0000") ) ;; End of let block diff --git a/template/admin.ctml b/template/admin.ctml index 54232a8..43c3bbd 100644 --- a/template/admin.ctml +++ b/template/admin.ctml @@ -102,6 +102,22 @@ + +
+

🎵 Track Requests

+
+ + + + + + +
+
+

Loading requests...

+
+
+

Music Library Management

@@ -346,6 +362,15 @@

+ +
+

📋 User Playlist Submissions

+

Review and approve user-submitted playlists. Approved playlists will be available for scheduling.

+
+

Loading submissions...

+
+
+
diff --git a/template/audio-player-frame.ctml b/template/audio-player-frame.ctml index 7c9772d..43ac13d 100644 --- a/template/audio-player-frame.ctml +++ b/template/audio-player-frame.ctml @@ -36,6 +36,7 @@ Loading... +
+ + +
+

🎵 Request a Track

+

Want to hear something specific? Submit a request and an admin will review it.

+
+ + + +
+ +
+

Recently Played Requests

+
+

Loading...

+
+
+