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
This commit is contained in:
parent
7351d7f800
commit
868b13af3d
|
|
@ -33,15 +33,15 @@
|
||||||
2) [ ] Make calendar editable, reschedule, ammend &c
|
2) [ ] Make calendar editable, reschedule, ammend &c
|
||||||
3) [ ] Add bumpers to landing page for scheduled programs
|
3) [ ] Add bumpers to landing page for scheduled programs
|
||||||
|
|
||||||
4) [0/8] User Profile pages
|
4) [5/8] User Profile pages
|
||||||
1) [ ] avatars
|
1) [X] avatars
|
||||||
2) [ ] default playlist
|
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
|
4) [ ] polls
|
||||||
5) [ ] Listener requests interface
|
5) [X] Listener requests interface
|
||||||
6) [ ] Calendar of upcoming scheduled 'shows'
|
6) [ ] Calendar of upcoming scheduled 'shows'
|
||||||
7) [ ] requests
|
7) [X] requests
|
||||||
8) [ ] Custom user playlists, with submission for station airing
|
8) [X] Custom user playlists, with submission for station airing
|
||||||
|
|
||||||
5) [0/2] Shuffle/Random queue
|
5) [0/2] Shuffle/Random queue
|
||||||
1) [ ] randomly run the whole library
|
1) [ ] randomly run the whole library
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
(:file "playlist-scheduler")
|
(:file "playlist-scheduler")
|
||||||
(:file "listener-stats")
|
(:file "listener-stats")
|
||||||
(:file "user-profile")
|
(:file "user-profile")
|
||||||
|
(:file "user-playlists")
|
||||||
(:file "track-requests")
|
(:file "track-requests")
|
||||||
(:file "auth-routes")
|
(:file "auth-routes")
|
||||||
(:file "frontend-partials")
|
(:file "frontend-partials")
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,18 @@
|
||||||
(listen-duration :integer)
|
(listen-duration :integer)
|
||||||
(completed :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
|
;; TODO: the radiance db interface is too basic to contain anything
|
||||||
;; but strings, integers, booleans, and maybe timestamps... we will
|
;; but strings, integers, booleans, and maybe timestamps... we will
|
||||||
;; need to rethink this. currently track/playlist relationships are
|
;; need to rethink this. currently track/playlist relationships are
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,16 @@
|
||||||
(icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3")))
|
(icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3")))
|
||||||
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
||||||
(if now-playing-stats
|
(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
|
;; TODO: it should be able to define a custom api-output for this
|
||||||
;; (api-output <clip-parser> :format "html"))
|
;; (api-output <clip-parser> :format "html"))
|
||||||
(setf (header "Content-Type") "text/html")
|
(setf (header "Content-Type") "text/html")
|
||||||
(clip:process-to-string
|
(clip:process-to-string
|
||||||
(load-template "partial/now-playing")
|
(load-template "partial/now-playing")
|
||||||
:stats now-playing-stats
|
: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
|
(progn
|
||||||
(setf (header "Content-Type") "text/html")
|
(setf (header "Content-Type") "text/html")
|
||||||
(clip:process-to-string
|
(clip:process-to-string
|
||||||
|
|
@ -124,10 +126,13 @@
|
||||||
(let* ((mount-name (or mount "asteroid.mp3"))
|
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||||
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
||||||
(if now-playing-stats
|
(if now-playing-stats
|
||||||
(api-output `(("status" . "success")
|
(let* ((title (cdr (assoc :title now-playing-stats)))
|
||||||
("title" . ,(cdr (assoc :title now-playing-stats)))
|
(favorite-count (or (get-track-favorite-count title) 0)))
|
||||||
("listeners" . ,(cdr (assoc :listeners now-playing-stats)))
|
(api-output `(("status" . "success")
|
||||||
("track_id" . ,(cdr (assoc :track-id now-playing-stats)))))
|
("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")
|
(api-output `(("status" . "offline")
|
||||||
("title" . "Stream Offline")
|
("title" . "Stream Offline")
|
||||||
("track_id" . nil)))))))
|
("track_id" . nil)))))))
|
||||||
|
|
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
(refresh-liquidsoap-status)
|
(refresh-liquidsoap-status)
|
||||||
(setup-stats-refresh)
|
(setup-stats-refresh)
|
||||||
(refresh-scheduler-status)
|
(refresh-scheduler-status)
|
||||||
|
(refresh-track-requests)
|
||||||
;; Update Liquidsoap status every 10 seconds
|
;; Update Liquidsoap status every 10 seconds
|
||||||
(set-interval refresh-liquidsoap-status 10000)
|
(set-interval refresh-liquidsoap-status 10000)
|
||||||
;; Update scheduler status every 30 seconds
|
;; Update scheduler status every 30 seconds
|
||||||
|
|
@ -1286,6 +1287,216 @@
|
||||||
(ps:chain console (error "Error loading scheduled playlist:" error))
|
(ps:chain console (error "Error loading scheduled playlist:" error))
|
||||||
(alert "Error loading scheduled playlist")))))
|
(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")
|
||||||
|
(+ "<button class=\"btn btn-success btn-sm\" onclick=\"approveRequest(" (ps:@ req id) ")\">✓ Approve</button>"
|
||||||
|
"<button class=\"btn btn-danger btn-sm\" onclick=\"rejectRequest(" (ps:@ req id) ")\">✗ Reject</button>"))
|
||||||
|
((= *current-request-tab* "approved")
|
||||||
|
"<span class=\"status-badge status-approved\">✓ Approved</span>")
|
||||||
|
((= *current-request-tab* "rejected")
|
||||||
|
"<span class=\"status-badge status-rejected\">✗ Rejected</span>")
|
||||||
|
((= *current-request-tab* "played")
|
||||||
|
"<span class=\"status-badge status-played\">🎵 Played</span>")
|
||||||
|
(t ""))))
|
||||||
|
(setf html (+ html
|
||||||
|
"<div class=\"request-item-admin\" data-request-id=\"" (ps:@ req id) "\">"
|
||||||
|
"<div class=\"request-info\">"
|
||||||
|
"<strong>" (ps:@ req title) "</strong>"
|
||||||
|
"<span class=\"request-user\">Requested by @" (ps:@ req username) "</span>"
|
||||||
|
(if (ps:@ req message)
|
||||||
|
(+ "<p class=\"request-message\">\"" (ps:@ req message) "\"</p>")
|
||||||
|
"")
|
||||||
|
"<span class=\"request-time\">" (format-request-time (ps:@ req created_at)) "</span>"
|
||||||
|
"</div>"
|
||||||
|
"<div class=\"request-actions\">"
|
||||||
|
actions-html
|
||||||
|
"</div>"
|
||||||
|
"</div>"))))))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) html))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) (+ "<p style=\"color: #888;\">No " *current-request-tab* " requests</p>")))))))
|
||||||
|
(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 "<table class='admin-table'><thead><tr><th>Playlist</th><th>User</th><th>Tracks</th><th>Submitted</th><th>Actions</th></tr></thead><tbody>"))
|
||||||
|
(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
|
||||||
|
"<tr>"
|
||||||
|
"<td><strong>" (aref pl "name") "</strong>"
|
||||||
|
(if (aref pl "description") (+ "<br><small>" (aref pl "description") "</small>") "")
|
||||||
|
"</td>"
|
||||||
|
"<td>" (or (aref pl "username") "Unknown") "</td>"
|
||||||
|
"<td>" (or (aref pl "trackCount") 0) " tracks</td>"
|
||||||
|
"<td>" submitted-date "</td>"
|
||||||
|
"<td>"
|
||||||
|
"<button class='btn btn-info btn-sm' onclick='previewPlaylist(" (aref pl "id") ")'>👁 Preview</button> "
|
||||||
|
"<button class='btn btn-success btn-sm' onclick='approvePlaylist(" (aref pl "id") ")'>✓ Approve</button> "
|
||||||
|
"<button class='btn btn-danger btn-sm' onclick='rejectPlaylist(" (aref pl "id") ")'>✗ Reject</button>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"))))))
|
||||||
|
(setf html (+ html "</tbody></table>"))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) html))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "<p class='no-data'>No playlists awaiting review</p>"))))))
|
||||||
|
(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) "<p class='error'>Error loading submissions</p>")))))))
|
||||||
|
|
||||||
|
(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
|
;; Make functions globally accessible for onclick handlers
|
||||||
(setf (ps:@ window go-to-page) go-to-page)
|
(setf (ps:@ window go-to-page) go-to-page)
|
||||||
(setf (ps:@ window previous-page) previous-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 load-current-scheduled-playlist) load-current-scheduled-playlist)
|
||||||
(setf (ps:@ window add-schedule-entry) add-schedule-entry)
|
(setf (ps:@ window add-schedule-entry) add-schedule-entry)
|
||||||
(setf (ps:@ window remove-schedule-entry) remove-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")
|
"Compiled JavaScript for admin dashboard - generated at load time")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,39 @@
|
||||||
;; Track last recorded title to avoid duplicate history entries
|
;; Track last recorded title to avoid duplicate history entries
|
||||||
(defvar *last-recorded-title-main* nil)
|
(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)
|
;; Record track to listening history (only if logged in)
|
||||||
(defun record-track-listen-main (title)
|
(defun record-track-listen-main (title)
|
||||||
(when (and title (not (= title "")) (not (= title "Loading..."))
|
(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))
|
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
|
||||||
(ps:create :method "POST"))
|
(ps:create :method "POST"))
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(when (ps:@ response ok)
|
(ps:@ response ok)))
|
||||||
(ps:chain console (log "Recorded listen:" title)))))
|
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
;; Silently fail - user might not be logged in
|
;; Silently fail - user might not be logged in
|
||||||
nil)))))
|
nil)))))
|
||||||
|
|
@ -206,7 +238,20 @@
|
||||||
;; Record if title changed
|
;; Record if title changed
|
||||||
(when (or (not old-title-el)
|
(when (or (not old-title-el)
|
||||||
(not (= (ps:@ old-title-el text-content) new-title)))
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (log "Could not fetch stream status:" error)))))))
|
(ps:chain console (log "Could not fetch stream status:" error)))))))
|
||||||
|
|
||||||
|
|
@ -582,6 +627,9 @@
|
||||||
(when is-frameset-page
|
(when is-frameset-page
|
||||||
(set-interval update-stream-information 10000)))
|
(set-interval update-stream-information 10000)))
|
||||||
|
|
||||||
|
;; Load user's favorites for highlight feature
|
||||||
|
(load-favorites-cache)
|
||||||
|
|
||||||
;; Update now playing
|
;; Update now playing
|
||||||
(update-now-playing)
|
(update-now-playing)
|
||||||
|
|
||||||
|
|
@ -650,7 +698,9 @@
|
||||||
(when (and data (or (= (ps:@ data status) "success")
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (remove "favorited"))
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error removing favorite:" error)))))
|
(ps:chain console (error "Error removing favorite:" error)))))
|
||||||
;; Add favorite
|
;; Add favorite
|
||||||
|
|
@ -667,7 +717,8 @@
|
||||||
(when (and data (or (= (ps:@ data status) "success")
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (add "favorited"))
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,46 @@
|
||||||
(load-favorites)
|
(load-favorites)
|
||||||
(load-top-artists)
|
(load-top-artists)
|
||||||
(load-activity-chart)
|
(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
|
||||||
|
"<div class=\"my-request-item " status-class "\">"
|
||||||
|
"<div class=\"request-title\">" (ps:@ req title) "</div>"
|
||||||
|
"<div class=\"request-status\">"
|
||||||
|
"<span class=\"status-badge " status-class "\">" status-icon " " (ps:@ req status) "</span>"
|
||||||
|
"</div>"
|
||||||
|
"</div>"))))))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) html))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-requests\">You haven't made any requests yet.</p>"))))))
|
||||||
|
(catch (lambda (error)
|
||||||
|
(ps:chain console (error "Error loading requests:" error))))))
|
||||||
|
|
||||||
;; Action functions
|
;; Action functions
|
||||||
(defun edit-profile ()
|
(defun edit-profile ()
|
||||||
|
|
@ -426,11 +465,402 @@
|
||||||
|
|
||||||
false))
|
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
|
||||||
|
"<div class=\"playlist-item " status-class "\">"
|
||||||
|
"<div class=\"playlist-info\">"
|
||||||
|
"<span class=\"playlist-name\">" (or (ps:@ pl name) (aref pl "name")) "</span>"
|
||||||
|
"<span class=\"playlist-meta\">" (or (ps:@ pl track-count) (aref pl "track-count") 0) " tracks</span>"
|
||||||
|
"</div>"
|
||||||
|
"<div class=\"playlist-actions\">"
|
||||||
|
"<span class=\"status-badge " status-class "\">" status-icon " " (or (ps:@ pl status) (aref pl "status")) "</span>"
|
||||||
|
(if (= (or (ps:@ pl status) (aref pl "status")) "draft")
|
||||||
|
(+ "<button class=\"btn btn-small\" onclick=\"editPlaylist(" playlist-id ")\">Edit</button>")
|
||||||
|
"")
|
||||||
|
"</div>"
|
||||||
|
"</div>"))))))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) html)))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No playlists yet. Create one to get started!</p>"))))))
|
||||||
|
(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
|
||||||
|
"<div class=\"playlist-track-item\" data-index=\"" index "\">"
|
||||||
|
"<span class=\"track-number\">" (+ index 1) ".</span>"
|
||||||
|
"<span class=\"track-title\">" (ps:@ track title) "</span>"
|
||||||
|
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
|
||||||
|
"<div class=\"track-controls\">"
|
||||||
|
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", -1)\" " (if (= index 0) "disabled" "") ">↑</button>"
|
||||||
|
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", 1)\" " (if (= index (- (ps:@ *current-playlist-tracks* length) 1)) "disabled" "") ">↓</button>"
|
||||||
|
"<button class=\"btn btn-tiny btn-danger\" onclick=\"removeTrackFromPlaylist(" index ")\">✕</button>"
|
||||||
|
"</div>"
|
||||||
|
"</div>")))))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) html))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "<p class=\"empty-message\">No tracks yet. Browse the library to add tracks!</p>")))))
|
||||||
|
|
||||||
|
(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
|
||||||
|
"<div class=\"library-track-item\">"
|
||||||
|
"<div class=\"track-info\">"
|
||||||
|
"<span class=\"track-title\">" (ps:@ track title) "</span>"
|
||||||
|
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
|
||||||
|
"<span class=\"track-album\">" (ps:@ track album) "</span>"
|
||||||
|
"</div>"
|
||||||
|
"<button class=\"btn btn-small btn-primary\" onclick=\"addTrackToPlaylist("
|
||||||
|
(ps:@ track id) ", '"
|
||||||
|
(ps:chain (ps:@ track title) (replace (ps:regex "/'/g") "\\'")) "', '"
|
||||||
|
(ps:chain (ps:@ track artist) (replace (ps:regex "/'/g") "\\'")) "', '"
|
||||||
|
(ps:chain (ps:@ track album) (replace (ps:regex "/'/g") "\\'")) "')\">+ Add</button>"
|
||||||
|
"</div>")))))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) html))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No tracks found</p>")))
|
||||||
|
;; 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) "<option value=\"\">All Artists</option>")
|
||||||
|
(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) "<option value=\"\">Select playlist to add to...</option>")
|
||||||
|
(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
|
;; Initialize on page load
|
||||||
(ps:chain window
|
(ps:chain window
|
||||||
(add-event-listener
|
(add-event-listener
|
||||||
"DOMContentLoaded"
|
"DOMContentLoaded"
|
||||||
load-profile-data))))
|
(lambda ()
|
||||||
|
(load-profile-data)
|
||||||
|
(load-my-playlists))))))
|
||||||
"Compiled JavaScript for profile page - generated at load time")
|
"Compiled JavaScript for profile page - generated at load time")
|
||||||
|
|
||||||
(defun generate-profile-js ()
|
(defun generate-profile-js ()
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,39 @@
|
||||||
;; Track the last recorded title to avoid duplicate history entries
|
;; Track the last recorded title to avoid duplicate history entries
|
||||||
(defvar *last-recorded-title* nil)
|
(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)
|
;; Record track to listening history (only if logged in)
|
||||||
(defun record-track-listen (title)
|
(defun record-track-listen (title)
|
||||||
(when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-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))
|
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
|
||||||
(ps:create :method "POST"))
|
(ps:create :method "POST"))
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(when (ps:@ response ok)
|
(ps:@ response ok)))
|
||||||
(ps:chain console (log "Recorded listen:" title)))))
|
(catch (lambda (error) nil)))))
|
||||||
(catch (lambda (error)
|
|
||||||
;; Silently fail - user might not be logged in
|
|
||||||
nil)))))
|
|
||||||
|
|
||||||
;; Update mini now playing display (for persistent player frame)
|
;; Update mini now playing display (for persistent player frame)
|
||||||
(defun update-mini-now-playing ()
|
(defun update-mini-now-playing ()
|
||||||
|
|
@ -233,10 +263,20 @@
|
||||||
;; Check if track changed and record to history
|
;; Check if track changed and record to history
|
||||||
(when (not (= (ps:@ el text-content) title))
|
(when (not (= (ps:@ el text-content) title))
|
||||||
(record-track-listen 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
|
(when track-id-el
|
||||||
(let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id))))
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (log "Could not fetch now playing:" error)))))))
|
(ps:chain console (log "Could not fetch now playing:" error)))))))
|
||||||
|
|
||||||
|
|
@ -269,7 +309,10 @@
|
||||||
(when (and data (or (= (ps:@ data status) "success")
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (remove "favorited"))
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error removing favorite:" error)))))
|
(ps:chain console (error "Error removing favorite:" error)))))
|
||||||
;; Add favorite
|
;; Add favorite
|
||||||
|
|
@ -286,7 +329,10 @@
|
||||||
(when (and data (or (= (ps:@ data status) "success")
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
(= (ps:@ data data status) "success")))
|
(= (ps:@ data data status) "success")))
|
||||||
(ps:chain btn class-list (add "favorited"))
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
|
|
@ -604,6 +650,9 @@
|
||||||
(defun init-persistent-player ()
|
(defun init-persistent-player ()
|
||||||
(let ((audio-element (ps:chain document (get-element-by-id "persistent-audio"))))
|
(let ((audio-element (ps:chain document (get-element-by-id "persistent-audio"))))
|
||||||
(when audio-element
|
(when audio-element
|
||||||
|
;; Load user's favorites for highlight feature
|
||||||
|
(load-favorites-cache-mini)
|
||||||
|
|
||||||
;; Try to enable low-latency mode if supported
|
;; Try to enable low-latency mode if supported
|
||||||
(when (ps:@ navigator media-session)
|
(when (ps:@ navigator media-session)
|
||||||
(setf (ps:@ navigator media-session metadata)
|
(setf (ps:@ navigator media-session metadata)
|
||||||
|
|
|
||||||
|
|
@ -163,11 +163,19 @@
|
||||||
(sort (copy-list *playlist-schedule*) #'< :key #'car))
|
(sort (copy-list *playlist-schedule*) #'< :key #'car))
|
||||||
|
|
||||||
(defun get-available-playlists ()
|
(defun get-available-playlists ()
|
||||||
"Get list of available playlist files from the playlists directory."
|
"Get list of available playlist files from the playlists directory and user-submissions."
|
||||||
(let ((playlists-dir (get-playlists-directory)))
|
(let ((playlists-dir (get-playlists-directory))
|
||||||
(when (probe-file playlists-dir)
|
(submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory))))
|
||||||
(mapcar #'file-namestring
|
(append
|
||||||
(directory (merge-pathnames "*.m3u" playlists-dir))))))
|
;; 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 ()
|
(defun get-server-time-info ()
|
||||||
"Get current server time information in both UTC and local timezone."
|
"Get current server time information in both UTC and local timezone."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1842,6 +1842,136 @@ body.popout-body .status-mini{
|
||||||
font-style: italic;
|
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{
|
.activity-chart{
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
@ -1912,3 +2042,299 @@ body.popout-body .status-mini{
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1475,6 +1475,116 @@
|
||||||
:color "#666"
|
:color "#666"
|
||||||
:font-style "italic")
|
: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 styling
|
||||||
(.activity-chart
|
(.activity-chart
|
||||||
:padding "15px"
|
:padding "15px"
|
||||||
|
|
@ -1532,4 +1642,256 @@
|
||||||
:font-style "italic"
|
:font-style "italic"
|
||||||
:text-align "center"
|
:text-align "center"
|
||||||
:padding "20px"))
|
: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
|
) ;; End of let block
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Track Requests -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h2>🎵 Track Requests</h2>
|
||||||
|
<div class="request-tabs" style="margin-bottom: 15px;">
|
||||||
|
<button class="btn btn-tab active" id="tab-pending" onclick="showRequestTab('pending')">⏳ Pending</button>
|
||||||
|
<button class="btn btn-tab" id="tab-approved" onclick="showRequestTab('approved')">✓ Approved</button>
|
||||||
|
<button class="btn btn-tab" id="tab-rejected" onclick="showRequestTab('rejected')">✗ Rejected</button>
|
||||||
|
<button class="btn btn-tab" id="tab-played" onclick="showRequestTab('played')">🎵 Played</button>
|
||||||
|
<button class="btn btn-secondary" onclick="refreshTrackRequests()" style="margin-left: 15px;">🔄 Refresh</button>
|
||||||
|
<span id="requests-status" style="margin-left: 15px;"></span>
|
||||||
|
</div>
|
||||||
|
<div id="pending-requests-container">
|
||||||
|
<p class="loading">Loading requests...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Music Library Management -->
|
<!-- Music Library Management -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>Music Library Management</h2>
|
<h2>Music Library Management</h2>
|
||||||
|
|
@ -346,6 +362,15 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- User Playlist Review -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h2>📋 User Playlist Submissions</h2>
|
||||||
|
<p>Review and approve user-submitted playlists. Approved playlists will be available for scheduling.</p>
|
||||||
|
<div id="user-playlists-container">
|
||||||
|
<p class="loading-message">Loading submissions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- User Management -->
|
<!-- User Management -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
</audio>
|
</audio>
|
||||||
|
|
||||||
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
|
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
|
||||||
|
<span class="favorite-count-mini" id="favorite-count-mini"></span>
|
||||||
|
|
||||||
<button class="btn-favorite-mini" id="favorite-btn-mini" onclick="toggleFavoriteMini()" title="Add to favorites">
|
<button class="btn-favorite-mini" id="favorite-btn-mini" onclick="toggleFavoriteMini()" title="Add to favorites">
|
||||||
<span class="star-icon">☆</span>
|
<span class="star-icon">☆</span>
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,24 @@
|
||||||
<p class="loading">Loading...</p>
|
<p class="loading">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Track Request Section -->
|
||||||
|
<div class="request-panel">
|
||||||
|
<h3>🎵 Request a Track</h3>
|
||||||
|
<p class="request-description">Want to hear something specific? Submit a request and an admin will review it.</p>
|
||||||
|
<div class="request-form">
|
||||||
|
<input type="text" id="request-title" class="request-input" placeholder="Suggest a track, artist, or album...">
|
||||||
|
<input type="text" id="request-message" class="request-input" placeholder="Why do you want to hear this? (optional)">
|
||||||
|
<button class="btn btn-primary" onclick="submitTrackRequest()">Submit Request</button>
|
||||||
|
</div>
|
||||||
|
<div id="request-status" class="request-status" style="display: none;"></div>
|
||||||
|
<div class="recent-requests">
|
||||||
|
<h4>Recently Played Requests</h4>
|
||||||
|
<div id="recent-requests-list">
|
||||||
|
<p class="no-requests">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,8 @@
|
||||||
<h3>🎵 Request a Track</h3>
|
<h3>🎵 Request a Track</h3>
|
||||||
<p class="request-description">Want to hear something specific? Submit a request!</p>
|
<p class="request-description">Want to hear something specific? Submit a request!</p>
|
||||||
<div class="request-form">
|
<div class="request-form">
|
||||||
<input type="text" id="request-title" placeholder="Artist - Track Title" class="request-input">
|
<input type="text" id="request-title" placeholder="Suggest a track, artist, or album..." class="request-input">
|
||||||
<input type="text" id="request-message" placeholder="Optional message (e.g., 'for my late night coding session')" class="request-input">
|
<input type="text" id="request-message" placeholder="Why do you want to hear this? (optional)" class="request-input">
|
||||||
<button onclick="submitTrackRequest()" class="btn btn-primary">Submit Request</button>
|
<button onclick="submitTrackRequest()" class="btn btn-primary">Submit Request</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="request-status" class="request-status" style="display: none;"></div>
|
<div id="request-status" class="request-status" style="display: none;"></div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
<p>Listeners: <span lquery="(text listeners)">1</span></p>
|
<p>Listeners: <span lquery="(text listeners)">1</span></p>
|
||||||
</c:using>
|
</c:using>
|
||||||
<input type="hidden" id="current-track-id" lquery="(val track-id)" value="">
|
<input type="hidden" id="current-track-id" lquery="(val track-id)" value="">
|
||||||
|
<input type="hidden" id="favorite-count-value" lquery="(val favorite-count)" value="0">
|
||||||
|
<p class="favorite-count" id="favorite-count-display"></p>
|
||||||
</c:then>
|
</c:then>
|
||||||
<c:else>
|
<c:else>
|
||||||
<c:if test="connection-error">
|
<c:if test="connection-error">
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- My Track Requests -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h2>🎵 My Track Requests</h2>
|
||||||
|
<div id="my-requests-list" class="requests-list">
|
||||||
|
<p class="loading-message">Loading your requests...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My Playlists -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h2>📝 My Playlists</h2>
|
||||||
|
<p class="section-description">Create custom playlists from the music library and submit them for station airplay!</p>
|
||||||
|
<div class="playlist-actions">
|
||||||
|
<button class="btn btn-primary" onclick="showCreatePlaylistModal()">➕ Create New Playlist</button>
|
||||||
|
<button class="btn btn-secondary" onclick="showLibraryBrowser()">🎵 Browse Library</button>
|
||||||
|
</div>
|
||||||
|
<div id="my-playlists-list" class="playlists-list">
|
||||||
|
<p class="loading-message">Loading your playlists...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Favorite Tracks -->
|
<!-- Favorite Tracks -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>❤️ Favorite Tracks</h2>
|
<h2>❤️ Favorite Tracks</h2>
|
||||||
|
|
@ -158,6 +179,76 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Playlist Modal -->
|
||||||
|
<div id="create-playlist-modal" class="modal" style="display: none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="modal-close" onclick="hideCreatePlaylistModal()">×</span>
|
||||||
|
<h2>Create New Playlist</h2>
|
||||||
|
<form id="create-playlist-form" onsubmit="return createPlaylist(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playlist-name">Playlist Name:</label>
|
||||||
|
<input type="text" id="playlist-name" name="name" required maxlength="100" placeholder="My Awesome Playlist">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playlist-description">Description (optional):</label>
|
||||||
|
<textarea id="playlist-description" name="description" maxlength="500" rows="3" placeholder="Describe your playlist..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="create-playlist-message" class="message"></div>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Playlist</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Library Browser Modal -->
|
||||||
|
<div id="library-browser-modal" class="modal" style="display: none; z-index: 1100;">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<span class="modal-close" onclick="hideLibraryBrowser()">×</span>
|
||||||
|
<h2>🎵 Music Library</h2>
|
||||||
|
<div class="library-controls">
|
||||||
|
<input type="text" id="library-search" placeholder="Search tracks..." onkeyup="searchLibrary()">
|
||||||
|
<select id="library-artist-filter" onchange="filterByArtist()">
|
||||||
|
<option value="">All Artists</option>
|
||||||
|
</select>
|
||||||
|
<select id="add-to-playlist-select">
|
||||||
|
<option value="">Select playlist to add to...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="library-tracks" class="library-tracks-list">
|
||||||
|
<p class="loading-message">Loading library...</p>
|
||||||
|
</div>
|
||||||
|
<div class="library-pagination">
|
||||||
|
<button class="btn btn-secondary" onclick="prevLibraryPage()" id="lib-prev-btn" disabled>← Previous</button>
|
||||||
|
<span id="library-page-info">Page 1</span>
|
||||||
|
<button class="btn btn-secondary" onclick="nextLibraryPage()" id="lib-next-btn">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Playlist Modal -->
|
||||||
|
<div id="edit-playlist-modal" class="modal" style="display: none;">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<span class="modal-close" onclick="hideEditPlaylistModal()">×</span>
|
||||||
|
<h2 id="edit-playlist-title">Edit Playlist</h2>
|
||||||
|
<div class="playlist-edit-header">
|
||||||
|
<input type="text" id="edit-playlist-name" placeholder="Playlist name">
|
||||||
|
<textarea id="edit-playlist-description" placeholder="Description..." rows="2"></textarea>
|
||||||
|
<button class="btn btn-secondary" onclick="savePlaylistMetadata()">Save Details</button>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-tracks-container">
|
||||||
|
<h3>Tracks in Playlist</h3>
|
||||||
|
<div id="playlist-tracks-list" class="playlist-tracks-sortable">
|
||||||
|
<p class="empty-message">No tracks yet. Browse the library to add tracks!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-edit-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="showLibraryBrowserForPlaylist()">➕ Add Tracks</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitPlaylistForReview()" id="submit-playlist-btn">📤 Submit for Review</button>
|
||||||
|
<button class="btn btn-danger" onclick="deleteCurrentPlaylist()">🗑️ Delete Playlist</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="current-edit-playlist-id" value="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Initialization handled by profile.js -->
|
<!-- Initialization handled by profile.js -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,18 @@
|
||||||
LIMIT ~a" user-id limit))
|
LIMIT ~a" user-id limit))
|
||||||
:alists)))
|
:alists)))
|
||||||
|
|
||||||
|
(defun get-requests-by-status (status &key (limit 50))
|
||||||
|
"Get requests by status with user info"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:raw (format nil "SELECT r._id, r.track_title, r.track_path, r.message, r.status, r.\"created-at\", u.username
|
||||||
|
FROM track_requests r
|
||||||
|
JOIN \"USERS\" u ON r.\"user-id\" = u._id
|
||||||
|
WHERE r.status = '~a'
|
||||||
|
ORDER BY r.\"created-at\" DESC
|
||||||
|
LIMIT ~a" status limit))
|
||||||
|
:alists)))
|
||||||
|
|
||||||
(defun get-recent-played-requests (&key (limit 10))
|
(defun get-recent-played-requests (&key (limit 10))
|
||||||
"Get recently played requests with user attribution"
|
"Get recently played requests with user attribution"
|
||||||
(with-db
|
(with-db
|
||||||
|
|
@ -168,6 +180,21 @@
|
||||||
("created_at" . ,(cdr (assoc :created-at r)))))
|
("created_at" . ,(cdr (assoc :created-at r)))))
|
||||||
requests)))))))
|
requests)))))))
|
||||||
|
|
||||||
|
(define-api asteroid/admin/requests/list (&optional (status "pending")) ()
|
||||||
|
"Get requests by status (pending, approved, rejected, played)"
|
||||||
|
(require-role :admin)
|
||||||
|
(with-error-handling
|
||||||
|
(let ((requests (get-requests-by-status status)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("requests" . ,(mapcar (lambda (r)
|
||||||
|
`(("id" . ,(cdr (assoc :_id r)))
|
||||||
|
("title" . ,(cdr (assoc :track-title r)))
|
||||||
|
("path" . ,(cdr (assoc :track-path r)))
|
||||||
|
("message" . ,(cdr (assoc :message r)))
|
||||||
|
("username" . ,(cdr (assoc :username r)))
|
||||||
|
("created_at" . ,(cdr (assoc :created-at r)))))
|
||||||
|
requests)))))))
|
||||||
|
|
||||||
(define-api asteroid/admin/requests/approved () ()
|
(define-api asteroid/admin/requests/approved () ()
|
||||||
"Get approved requests ready to queue"
|
"Get approved requests ready to queue"
|
||||||
(require-role :admin)
|
(require-role :admin)
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,10 @@
|
||||||
(format t "Error getting current user: ~a~%" e)
|
(format t "Error getting current user: ~a~%" e)
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
|
(defun get-current-user-id ()
|
||||||
|
"Get the currently authenticated user's ID from session"
|
||||||
|
(session:field "user-id"))
|
||||||
|
|
||||||
(defun require-authentication (&key (api nil))
|
(defun require-authentication (&key (api nil))
|
||||||
"Require user to be authenticated.
|
"Require user to be authenticated.
|
||||||
Returns T if authenticated, NIL if not (after emitting error response).
|
Returns T if authenticated, NIL if not (after emitting error response).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,421 @@
|
||||||
|
(in-package :asteroid)
|
||||||
|
|
||||||
|
;;; ==========================================================================
|
||||||
|
;;; User Playlists - Custom playlist creation and submission
|
||||||
|
;;; ==========================================================================
|
||||||
|
|
||||||
|
;;; Status values: "draft", "submitted", "approved", "rejected", "scheduled"
|
||||||
|
|
||||||
|
;; Helper to get value from Postmodern alist (keys are uppercase symbols)
|
||||||
|
(defun aget (key alist)
|
||||||
|
"Get value from alist using string-equal comparison for key"
|
||||||
|
(cdr (assoc key alist :test (lambda (a b) (string-equal (string a) (string b))))))
|
||||||
|
|
||||||
|
(defun get-user-playlists (user-id &optional status)
|
||||||
|
"Get all playlists for a user, optionally filtered by status"
|
||||||
|
(with-db
|
||||||
|
(if status
|
||||||
|
(postmodern:query
|
||||||
|
(:order-by
|
||||||
|
(:select '* :from 'user_playlists
|
||||||
|
:where (:and (:= 'user-id user-id)
|
||||||
|
(:= 'status status)))
|
||||||
|
(:desc 'created-date))
|
||||||
|
:alists)
|
||||||
|
(postmodern:query
|
||||||
|
(:order-by
|
||||||
|
(:select '* :from 'user_playlists
|
||||||
|
:where (:= 'user-id user-id))
|
||||||
|
(:desc 'created-date))
|
||||||
|
:alists))))
|
||||||
|
|
||||||
|
(defun get-user-playlist-by-id (playlist-id)
|
||||||
|
"Get a single playlist by ID"
|
||||||
|
(with-db
|
||||||
|
(first (postmodern:query
|
||||||
|
(:select '* :from 'user_playlists
|
||||||
|
:where (:= '_id playlist-id))
|
||||||
|
:alists))))
|
||||||
|
|
||||||
|
(defun create-user-playlist (user-id name description)
|
||||||
|
"Create a new user playlist"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:insert-into 'user_playlists
|
||||||
|
:set 'user-id user-id
|
||||||
|
'name name
|
||||||
|
'description (or description "")
|
||||||
|
'track-ids "[]"
|
||||||
|
'status "draft"
|
||||||
|
'created-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER"))
|
||||||
|
:none)
|
||||||
|
;; Return the created playlist
|
||||||
|
(first (postmodern:query
|
||||||
|
(:order-by
|
||||||
|
(:select '* :from 'user_playlists
|
||||||
|
:where (:= 'user-id user-id))
|
||||||
|
(:desc '_id))
|
||||||
|
:alists))))
|
||||||
|
|
||||||
|
(defun update-user-playlist-tracks (playlist-id track-ids-json)
|
||||||
|
"Update the track list for a playlist"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:update 'user_playlists
|
||||||
|
:set 'track-ids track-ids-json
|
||||||
|
:where (:= '_id playlist-id))
|
||||||
|
:none)))
|
||||||
|
|
||||||
|
(defun update-user-playlist-metadata (playlist-id name description)
|
||||||
|
"Update playlist name and description"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:update 'user_playlists
|
||||||
|
:set 'name name
|
||||||
|
'description description
|
||||||
|
:where (:= '_id playlist-id))
|
||||||
|
:none)))
|
||||||
|
|
||||||
|
(defun submit-user-playlist (playlist-id)
|
||||||
|
"Submit a playlist for admin review"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:update 'user_playlists
|
||||||
|
:set 'status "submitted"
|
||||||
|
'submitted-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER")
|
||||||
|
:where (:= '_id playlist-id))
|
||||||
|
:none)))
|
||||||
|
|
||||||
|
(defun get-submitted-playlists ()
|
||||||
|
"Get all submitted playlists awaiting review (admin)"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:order-by
|
||||||
|
(:select 'p.* 'u.username
|
||||||
|
:from (:as 'user_playlists 'p)
|
||||||
|
:left-join (:as (:raw "\"USERS\"") 'u) :on (:= 'p.user-id 'u._id)
|
||||||
|
:where (:= 'p.status "submitted"))
|
||||||
|
(:asc 'p.submitted-date))
|
||||||
|
:alists)))
|
||||||
|
|
||||||
|
(defun review-user-playlist (playlist-id admin-id status notes)
|
||||||
|
"Approve or reject a submitted playlist"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:update 'user_playlists
|
||||||
|
:set 'status status
|
||||||
|
'reviewed-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER")
|
||||||
|
'reviewed-by admin-id
|
||||||
|
'review-notes (or notes "")
|
||||||
|
:where (:= '_id playlist-id))
|
||||||
|
:none)))
|
||||||
|
|
||||||
|
(defun generate-user-playlist-m3u (playlist-id)
|
||||||
|
"Generate M3U file content for a user playlist"
|
||||||
|
(let* ((playlist (get-user-playlist-by-id playlist-id))
|
||||||
|
(track-ids-json (aget "TRACK-IDS" playlist))
|
||||||
|
(track-ids (when (and track-ids-json (not (string= track-ids-json "[]")))
|
||||||
|
(cl-json:decode-json-from-string track-ids-json)))
|
||||||
|
(name (aget "NAME" playlist))
|
||||||
|
(description (aget "DESCRIPTION" playlist))
|
||||||
|
(user-id (aget "USER-ID" playlist))
|
||||||
|
(username (get-username-by-id user-id)))
|
||||||
|
(with-output-to-string (out)
|
||||||
|
(format out "#EXTM3U~%")
|
||||||
|
(format out "#PLAYLIST:~a~%" name)
|
||||||
|
;; Use description as the phase name if provided, otherwise use playlist name
|
||||||
|
(format out "#PHASE:~a~%" (if (and description (not (string= description "")))
|
||||||
|
description
|
||||||
|
name))
|
||||||
|
(format out "#CURATOR:~a~%" (or username "Anonymous"))
|
||||||
|
(format out "~%")
|
||||||
|
;; Add tracks
|
||||||
|
(dolist (track-id track-ids)
|
||||||
|
(let ((track (get-track-by-id track-id)))
|
||||||
|
(when track
|
||||||
|
(let* ((title (dm:field track "title"))
|
||||||
|
(artist (dm:field track "artist"))
|
||||||
|
(file-path (dm:field track "file-path"))
|
||||||
|
(docker-path (convert-to-docker-path file-path)))
|
||||||
|
(format out "#EXTINF:-1,~a - ~a~%" artist title)
|
||||||
|
(format out "~a~%" docker-path))))))))
|
||||||
|
|
||||||
|
(defun save-user-playlist-m3u (playlist-id)
|
||||||
|
"Save user playlist as M3U file in playlists/user-submissions/"
|
||||||
|
(let* ((playlist (get-user-playlist-by-id playlist-id))
|
||||||
|
(name (aget "NAME" playlist))
|
||||||
|
(user-id (aget "USER-ID" playlist))
|
||||||
|
(username (get-username-by-id user-id))
|
||||||
|
(safe-name (cl-ppcre:regex-replace-all "[^a-zA-Z0-9-_]" name "-"))
|
||||||
|
(filename (format nil "~a-~a-~a.m3u" username safe-name playlist-id))
|
||||||
|
(submissions-dir (merge-pathnames "playlists/user-submissions/"
|
||||||
|
(asdf:system-source-directory :asteroid)))
|
||||||
|
(filepath (merge-pathnames filename submissions-dir)))
|
||||||
|
;; Ensure directory exists
|
||||||
|
(ensure-directories-exist submissions-dir)
|
||||||
|
;; Write M3U file
|
||||||
|
(with-open-file (out filepath :direction :output
|
||||||
|
:if-exists :supersede
|
||||||
|
:if-does-not-exist :create)
|
||||||
|
(write-string (generate-user-playlist-m3u playlist-id) out))
|
||||||
|
filename))
|
||||||
|
|
||||||
|
(defun get-username-by-id (user-id)
|
||||||
|
"Get username for a user ID"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:select 'username :from (:raw "\"USERS\"") :where (:= '_id user-id))
|
||||||
|
:single)))
|
||||||
|
|
||||||
|
(defun delete-user-playlist (playlist-id user-id)
|
||||||
|
"Delete a user playlist (only if owned by user and in draft status)"
|
||||||
|
(with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:delete-from 'user_playlists
|
||||||
|
:where (:and (:= '_id playlist-id)
|
||||||
|
(:= 'user-id user-id)
|
||||||
|
(:= 'status "draft")))
|
||||||
|
:none)))
|
||||||
|
|
||||||
|
;;; ==========================================================================
|
||||||
|
;;; API Endpoints
|
||||||
|
;;; ==========================================================================
|
||||||
|
|
||||||
|
(define-api asteroid/library/browse (&optional search artist album page) ()
|
||||||
|
"Browse the music library - available to all authenticated users"
|
||||||
|
(require-authentication)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((page-num (or (and page (parse-integer page :junk-allowed t)) 1))
|
||||||
|
(per-page 50)
|
||||||
|
(offset (* (1- page-num) per-page))
|
||||||
|
(tracks (with-db
|
||||||
|
(cond
|
||||||
|
;; Search by text
|
||||||
|
(search
|
||||||
|
(let ((search-pattern (format nil "%~a%" search)))
|
||||||
|
(postmodern:query
|
||||||
|
(:raw (format nil "SELECT * FROM tracks WHERE title ILIKE $1 OR artist ILIKE $1 OR album ILIKE $1 ORDER BY artist, album, title LIMIT ~a OFFSET ~a"
|
||||||
|
per-page offset))
|
||||||
|
search-pattern
|
||||||
|
:alists)))
|
||||||
|
;; Filter by artist
|
||||||
|
(artist
|
||||||
|
(postmodern:query
|
||||||
|
(:raw (format nil "SELECT * FROM tracks WHERE artist = $1 ORDER BY album, title LIMIT ~a OFFSET ~a"
|
||||||
|
per-page offset))
|
||||||
|
artist
|
||||||
|
:alists))
|
||||||
|
;; Filter by album
|
||||||
|
(album
|
||||||
|
(postmodern:query
|
||||||
|
(:raw (format nil "SELECT * FROM tracks WHERE album = $1 ORDER BY title LIMIT ~a OFFSET ~a"
|
||||||
|
per-page offset))
|
||||||
|
album
|
||||||
|
:alists))
|
||||||
|
;; All tracks
|
||||||
|
(t
|
||||||
|
(postmodern:query
|
||||||
|
(:raw (format nil "SELECT * FROM tracks ORDER BY artist, album, title LIMIT ~a OFFSET ~a"
|
||||||
|
per-page offset))
|
||||||
|
:alists)))))
|
||||||
|
;; Get unique artists for filtering
|
||||||
|
(artists (with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:order-by
|
||||||
|
(:select (:distinct 'artist) :from 'tracks)
|
||||||
|
'artist)
|
||||||
|
:column)))
|
||||||
|
;; Get total count
|
||||||
|
(total-count (with-db
|
||||||
|
(postmodern:query
|
||||||
|
(:select (:count '*) :from 'tracks)
|
||||||
|
:single))))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("tracks" . ,(mapcar (lambda (track)
|
||||||
|
`(("id" . ,(aget "-ID" track))
|
||||||
|
("title" . ,(aget "TITLE" track))
|
||||||
|
("artist" . ,(aget "ARTIST" track))
|
||||||
|
("album" . ,(aget "ALBUM" track))
|
||||||
|
("duration" . ,(aget "DURATION" track))
|
||||||
|
("format" . ,(aget "FORMAT" track))))
|
||||||
|
tracks))
|
||||||
|
("artists" . ,artists)
|
||||||
|
("page" . ,page-num)
|
||||||
|
("per-page" . ,per-page)
|
||||||
|
("total" . ,total-count))))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/playlists () ()
|
||||||
|
"Get current user's playlists"
|
||||||
|
(require-authentication)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((user-id (get-current-user-id))
|
||||||
|
(playlists (get-user-playlists user-id)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("playlists" . ,(mapcar (lambda (pl)
|
||||||
|
`(("id" . ,(aget "-ID" pl))
|
||||||
|
("name" . ,(aget "NAME" pl))
|
||||||
|
("description" . ,(aget "DESCRIPTION" pl))
|
||||||
|
("track-count" . ,(let ((ids (aget "TRACK-IDS" pl)))
|
||||||
|
(if (and ids (not (string= ids "[]")))
|
||||||
|
(length (cl-json:decode-json-from-string ids))
|
||||||
|
0)))
|
||||||
|
("status" . ,(aget "STATUS" pl))
|
||||||
|
("created-date" . ,(aget "CREATED-DATE" pl))))
|
||||||
|
playlists)))))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/playlists/get (id) ()
|
||||||
|
"Get a specific playlist with full track details"
|
||||||
|
(require-authentication)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((user-id (get-current-user-id))
|
||||||
|
(playlist-id (when (and id (not (string= id "null"))) (parse-integer id :junk-allowed t)))
|
||||||
|
(playlist (when playlist-id (get-user-playlist-by-id playlist-id))))
|
||||||
|
(if (and playlist (= (aget "USER-ID" playlist) user-id))
|
||||||
|
(let* ((track-ids-json (aget "TRACK-IDS" playlist))
|
||||||
|
(track-ids (when (and track-ids-json (not (string= track-ids-json "[]")))
|
||||||
|
(cl-json:decode-json-from-string track-ids-json)))
|
||||||
|
;; Filter out null values from track-ids
|
||||||
|
(valid-track-ids (remove-if #'null track-ids))
|
||||||
|
(tracks (mapcar (lambda (tid)
|
||||||
|
(when (and tid (integerp tid))
|
||||||
|
(let ((track (get-track-by-id tid)))
|
||||||
|
(when track
|
||||||
|
`(("id" . ,tid)
|
||||||
|
("title" . ,(dm:field track "title"))
|
||||||
|
("artist" . ,(dm:field track "artist"))
|
||||||
|
("album" . ,(dm:field track "album")))))))
|
||||||
|
valid-track-ids)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("playlist" . (("id" . ,(aget "-ID" playlist))
|
||||||
|
("name" . ,(aget "NAME" playlist))
|
||||||
|
("description" . ,(aget "DESCRIPTION" playlist))
|
||||||
|
("status" . ,(aget "STATUS" playlist))
|
||||||
|
("tracks" . ,(remove nil tracks)))))))
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . "Playlist not found"))
|
||||||
|
:status 404)))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/playlists/create (name &optional description) ()
|
||||||
|
"Create a new playlist"
|
||||||
|
(require-authentication)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((user-id (get-current-user-id))
|
||||||
|
(playlist (create-user-playlist user-id name description)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . "Playlist created")
|
||||||
|
("playlist" . (("id" . ,(aget "-ID" playlist))
|
||||||
|
("name" . ,(aget "NAME" playlist)))))))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/playlists/update (id &optional name description tracks) ()
|
||||||
|
"Update a playlist (name, description, or tracks)"
|
||||||
|
(require-authentication)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((user-id (get-current-user-id))
|
||||||
|
(playlist-id (parse-integer id :junk-allowed t))
|
||||||
|
(playlist (get-user-playlist-by-id playlist-id)))
|
||||||
|
(if (and playlist
|
||||||
|
(= (aget "USER-ID" playlist) user-id)
|
||||||
|
(string= (aget "STATUS" playlist) "draft"))
|
||||||
|
(progn
|
||||||
|
(when (or name description)
|
||||||
|
(update-user-playlist-metadata playlist-id
|
||||||
|
(or name (aget "NAME" playlist))
|
||||||
|
(or description (aget "DESCRIPTION" playlist))))
|
||||||
|
(when tracks
|
||||||
|
(update-user-playlist-tracks playlist-id tracks))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . "Playlist updated"))))
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . "Cannot update playlist (not found, not owned, or already submitted)"))
|
||||||
|
:status 400)))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/playlists/submit (id) ()
|
||||||
|
"Submit a playlist for admin review"
|
||||||
|
(require-authentication)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((user-id (get-current-user-id))
|
||||||
|
(playlist-id (parse-integer id :junk-allowed t))
|
||||||
|
(playlist (get-user-playlist-by-id playlist-id)))
|
||||||
|
(if (and playlist
|
||||||
|
(= (aget "USER-ID" playlist) user-id)
|
||||||
|
(string= (aget "STATUS" playlist) "draft"))
|
||||||
|
(let ((track-ids-json (aget "TRACK-IDS" playlist)))
|
||||||
|
(if (and track-ids-json
|
||||||
|
(not (string= track-ids-json "[]"))
|
||||||
|
(> (length (cl-json:decode-json-from-string track-ids-json)) 0))
|
||||||
|
(progn
|
||||||
|
(submit-user-playlist playlist-id)
|
||||||
|
;; Generate M3U file
|
||||||
|
(let ((filename (save-user-playlist-m3u playlist-id)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . "Playlist submitted for review")
|
||||||
|
("filename" . ,filename)))))
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . "Cannot submit empty playlist"))
|
||||||
|
:status 400)))
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . "Cannot submit playlist"))
|
||||||
|
:status 400)))))
|
||||||
|
|
||||||
|
(define-api asteroid/user/playlists/delete (id) ()
|
||||||
|
"Delete a draft playlist"
|
||||||
|
(require-authentication)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((user-id (get-current-user-id))
|
||||||
|
(playlist-id (parse-integer id :junk-allowed t)))
|
||||||
|
(delete-user-playlist playlist-id user-id)
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . "Playlist deleted"))))))
|
||||||
|
|
||||||
|
;;; Admin endpoints for reviewing user playlists
|
||||||
|
|
||||||
|
(define-api asteroid/admin/user-playlists () ()
|
||||||
|
"Get all submitted playlists awaiting review"
|
||||||
|
(require-role :admin)
|
||||||
|
(with-error-handling
|
||||||
|
(let ((playlists (get-submitted-playlists)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("playlists" . ,(mapcar (lambda (pl)
|
||||||
|
(let* ((track-ids-json (aget "TRACK-IDS" pl))
|
||||||
|
(track-count (if (and track-ids-json
|
||||||
|
(stringp track-ids-json)
|
||||||
|
(not (string= track-ids-json "[]")))
|
||||||
|
(length (cl-json:decode-json-from-string track-ids-json))
|
||||||
|
0)))
|
||||||
|
`(("id" . ,(aget "-ID" pl))
|
||||||
|
("name" . ,(aget "NAME" pl))
|
||||||
|
("description" . ,(aget "DESCRIPTION" pl))
|
||||||
|
("username" . ,(aget "USERNAME" pl))
|
||||||
|
("trackCount" . ,track-count)
|
||||||
|
("submittedDate" . ,(aget "SUBMITTED-DATE" pl)))))
|
||||||
|
playlists)))))))
|
||||||
|
|
||||||
|
(define-api asteroid/admin/user-playlists/review (id action &optional notes) ()
|
||||||
|
"Approve or reject a submitted playlist"
|
||||||
|
(require-role :admin)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((admin-id (get-current-user-id))
|
||||||
|
(playlist-id (parse-integer id :junk-allowed t))
|
||||||
|
(new-status (cond ((string= action "approve") "approved")
|
||||||
|
((string= action "reject") "rejected")
|
||||||
|
(t nil))))
|
||||||
|
(if new-status
|
||||||
|
(progn
|
||||||
|
(review-user-playlist playlist-id admin-id new-status notes)
|
||||||
|
;; Generate/regenerate M3U file when approving
|
||||||
|
(when (string= action "approve")
|
||||||
|
(save-user-playlist-m3u playlist-id))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("message" . ,(format nil "Playlist ~a" new-status)))))
|
||||||
|
(api-output `(("status" . "error")
|
||||||
|
("message" . "Invalid action (use 'approve' or 'reject')"))
|
||||||
|
:status 400)))))
|
||||||
|
|
||||||
|
(define-api asteroid/admin/user-playlists/preview (id) ()
|
||||||
|
"Preview M3U content for a submitted playlist"
|
||||||
|
(require-role :admin)
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((playlist-id (parse-integer id :junk-allowed t))
|
||||||
|
(m3u-content (generate-user-playlist-m3u playlist-id)))
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("m3u" . ,m3u-content))))))
|
||||||
|
|
@ -69,6 +69,21 @@
|
||||||
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE \"user-id\" = ~a" user-id))
|
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE \"user-id\" = ~a" user-id))
|
||||||
:single)))
|
:single)))
|
||||||
|
|
||||||
|
(defun get-track-favorite-count (track-title)
|
||||||
|
"Get count of how many users have favorited a track by title"
|
||||||
|
(if (and track-title (not (string= track-title "")))
|
||||||
|
(handler-case
|
||||||
|
(with-db
|
||||||
|
(let* ((escaped-title (sql-escape-string track-title))
|
||||||
|
(result (postmodern:query
|
||||||
|
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE track_title = '~a'" escaped-title))
|
||||||
|
:single)))
|
||||||
|
(or result 0)))
|
||||||
|
(error (e)
|
||||||
|
(declare (ignore e))
|
||||||
|
0))
|
||||||
|
0))
|
||||||
|
|
||||||
;;; ==========================================================================
|
;;; ==========================================================================
|
||||||
;;; Listening History - Per-user track play history
|
;;; Listening History - Per-user track play history
|
||||||
;;; ==========================================================================
|
;;; ==========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue