diff --git a/asteroid.asd b/asteroid.asd index 305afd1..f09308b 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -64,6 +64,7 @@ (:file "playlist-scheduler") (:file "listener-stats") (:file "user-profile") + (:file "track-requests") (:file "auth-routes") (:file "frontend-partials") (:file "asteroid"))) diff --git a/asteroid.lisp b/asteroid.lisp index 8940159..a88e22c 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -1167,13 +1167,14 @@ ("session_count" . 0) ("favorite_genre" . "Ambient")))))))) -(define-api asteroid/user/recent-tracks (&optional (limit "3")) () +(define-api asteroid/user/recent-tracks (&optional (limit "3") (offset "0")) () "Get recently played tracks for user" (require-authentication) (with-error-handling (let* ((user-id (session:field "user-id")) - (limit-int (parse-integer limit :junk-allowed t)) - (history (get-listening-history user-id :limit (or limit-int 3)))) + (limit-int (or (parse-integer limit :junk-allowed t) 3)) + (offset-int (or (parse-integer offset :junk-allowed t) 0)) + (history (get-listening-history user-id :limit limit-int :offset offset-int))) (api-output `(("status" . "success") ("tracks" . ,(mapcar (lambda (h) `(("title" . ,(or (cdr (assoc :track-title h)) diff --git a/migrations/007-track-requests.sql b/migrations/007-track-requests.sql new file mode 100644 index 0000000..50a984c --- /dev/null +++ b/migrations/007-track-requests.sql @@ -0,0 +1,31 @@ +-- Migration 007: Track Request System +-- Allows users to request tracks for the stream with social attribution + +-- Track requests table +CREATE TABLE IF NOT EXISTS track_requests ( + _id SERIAL PRIMARY KEY, + "user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE, + track_title TEXT NOT NULL, -- Track title (Artist - Title format) + track_path TEXT, -- Optional: path to file if known + message TEXT, -- Optional message from requester + status TEXT DEFAULT 'pending', -- pending, approved, rejected, played + "created-at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "reviewed-at" TIMESTAMP, -- When admin reviewed + "reviewed-by" INTEGER REFERENCES "USERS"(_id), + "played-at" TIMESTAMP -- When it was actually played +); + +-- Create indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_track_requests_user_id ON track_requests("user-id"); +CREATE INDEX IF NOT EXISTS idx_track_requests_status ON track_requests(status); +CREATE INDEX IF NOT EXISTS idx_track_requests_created ON track_requests("created-at"); + +-- Grant permissions +GRANT ALL PRIVILEGES ON track_requests TO asteroid; +GRANT ALL PRIVILEGES ON SEQUENCE track_requests__id_seq TO asteroid; + +-- Verification +DO $$ +BEGIN + RAISE NOTICE 'Migration 007: Track requests table created successfully!'; +END $$; diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index e62da0a..cadbff4 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -721,7 +721,77 @@ (when (and *popout-window* (ps:@ *popout-window* closed)) (update-popout-button nil) (setf *popout-window* nil))) - 1000))) + 1000) + + ;; Track Request Functions + (defun submit-track-request () + (let ((title-input (ps:chain document (get-element-by-id "request-title"))) + (message-input (ps:chain document (get-element-by-id "request-message"))) + (status-div (ps:chain document (get-element-by-id "request-status")))) + (when (and title-input message-input status-div) + (let ((title (ps:@ title-input value)) + (message (ps:@ message-input value))) + (if (or (not title) (= title "")) + (progn + (setf (ps:@ status-div style display) "block") + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Please enter a track title")) + (progn + (setf (ps:@ status-div style display) "block") + (setf (ps:@ status-div class-name) "request-status info") + (setf (ps:@ status-div text-content) "Submitting request...") + (ps:chain + (fetch (+ "/api/asteroid/requests/submit?title=" (encode-u-r-i-component title) + (if message (+ "&message=" (encode-u-r-i-component message)) "")) + (ps:create :method "POST")) + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + (progn + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Please log in to submit requests") + nil)))) + (then (lambda (data) + (when data + (let ((status (or (ps:@ data data status) (ps:@ data status)))) + (if (= status "success") + (progn + (setf (ps:@ status-div class-name) "request-status success") + (setf (ps:@ status-div text-content) "Request submitted! An admin will review it soon.") + (setf (ps:@ title-input value) "") + (setf (ps:@ message-input value) "")) + (progn + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Failed to submit request"))))))) + (catch (lambda (error) + (ps:chain console (error "Error submitting request:" error)) + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Error submitting request")))))))))) + + (defun load-recent-requests () + (let ((container (ps:chain document (get-element-by-id "recent-requests-list")))) + (when container + (ps:chain + (fetch "/api/asteroid/requests/recent") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (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) + (setf html (+ html "
" + "" (ps:@ req title) "" + "Requested by @" (ps:@ req username) "" + "
"))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No recent requests yet. Be the first!

"))))) + (catch (lambda (error) + (ps:chain console (log "Could not load recent requests:" error)))))))) + + ;; Load recent requests on page load + (load-recent-requests))) "Compiled JavaScript for front-page - generated at load time") (defun generate-front-page-js () diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index a6da081..dd7c954 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -32,20 +32,31 @@ :day "numeric"))))) (defun format-relative-time (date-string) - (let* ((date (ps:new (-date date-string))) - (now (ps:new (-date))) - (diff-ms (- now date)) - (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) - (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) - (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) - (cond - ((> diff-days 0) - (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) - ((> diff-hours 0) - (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) - ((> diff-minutes 0) - (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) - (t "Just now")))) + (when (not date-string) + (return-from format-relative-time "Unknown")) + ;; Convert PostgreSQL timestamp format to ISO format + ;; "2025-12-21 09:22:58.215986" -> "2025-12-21T09:22:58.215986Z" + (let* ((iso-string (if (and (ps:@ date-string replace) + (ps:chain date-string (includes " "))) + (+ (ps:chain date-string (replace " " "T")) "Z") + date-string)) + (date (ps:new (-date iso-string))) + (now (ps:new (-date)))) + ;; Check if date is valid + (when (ps:chain -number (is-na-n (ps:chain date (get-time)))) + (return-from format-relative-time "Recently")) + (let* ((diff-ms (- now date)) + (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) + (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) + (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) + (cond + ((> diff-days 0) + (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) + ((> diff-hours 0) + (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) + ((> diff-minutes 0) + (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) + (t "Just now"))))) (defun format-duration (seconds) (let ((hours (ps:chain -math (floor (/ seconds 3600)))) @@ -297,8 +308,13 @@ (ps:chain activity (for-each (lambda (day) (let* ((count (or (ps:@ day track_count) 0)) (height (ps:chain -math (round (* (/ count max-count) 100)))) - (date-str (ps:@ day day)) - (date-parts (ps:chain date-str (split "-"))) + (date-raw (ps:@ day day)) + (date-str (if (and date-raw (ps:@ date-raw to-string)) + (ps:chain date-raw (to-string)) + (+ "" date-raw))) + (date-parts (if (and date-str (ps:@ date-str split)) + (ps:chain date-str (split "-")) + (array))) (day-label (if (> (ps:@ date-parts length) 2) (ps:getprop date-parts 2) ""))) @@ -345,10 +361,36 @@ (load-activity-chart) (load-avatar)) + ;; Track offset for pagination + (defvar *recent-tracks-offset* 3) + ;; Action functions (defun load-more-recent-tracks () (ps:chain console (log "Loading more recent tracks...")) - (show-message "Loading more tracks..." "info")) + (ps:chain + (fetch (+ "/api/asteroid/user/recent-tracks?limit=10&offset=" *recent-tracks-offset*)) + (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 "recent-tracks-list")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (progn + (ps:chain (ps:@ data tracks) (for-each (lambda (track) + (let ((item (ps:chain document (create-element "div")))) + (setf (ps:@ item class-name) "track-item") + (setf (ps:@ item inner-h-t-m-l) + (+ "" (or (ps:@ track title) "Unknown") "" + "" (or (ps:@ track played_at) "") "")) + (ps:chain container (append-child item)))))) + (setf *recent-tracks-offset* (+ *recent-tracks-offset* (ps:@ data tracks length))) + (show-message (+ "Loaded " (ps:@ data tracks length) " more tracks") "success")) + (show-message "No more tracks to load" "info")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading more tracks:" error)) + (show-message "Error loading tracks" "error"))))) (defun edit-profile () (ps:chain console (log "Edit profile clicked")) diff --git a/static/asteroid.css b/static/asteroid.css index cf58547..77b3c5a 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1754,6 +1754,94 @@ body.popout-body .status-mini{ opacity: 1; } +.request-panel{ + background: rgba(0, 255, 0, 0.05); + border: 1px solid #333; + border-radius: 8px; + padding: 20px; + margin-top: 20px; +} + +.request-description{ + color: #888; + margin-bottom: 15px; +} + +.request-form{ + display: flex; + flex-direction: column; + gap: 10px; +} + +.request-input{ + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + padding: 10px; + color: #00cc00; + font-size: 1em; +} + +.request-input:focus{ + border-color: #00cc00; + outline: none; +} + +.request-status{ + padding: 10px; + border-radius: 4px; + margin-top: 10px; + text-align: center; +} + +.request-status.success{ + background: rgba(0, 255, 0, 0.2); + color: #00ff00; +} + +.request-status.error{ + background: rgba(255, 0, 0, 0.2); + color: #ff6b6b; +} + +.request-status.info{ + background: rgba(0, 150, 255, 0.2); + color: #66b3ff; +} + +.recent-requests{ + margin-top: 20px; + border-top: 1px solid #333; + padding-top: 15px; +} + +.recent-requests h4{ + color: #888; + margin-bottom: 10px; +} + +.request-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #222; +} + +.request-title{ + color: #00cc00; +} + +.request-by{ + color: #666; + font-size: 0.9em; +} + +.no-requests{ + color: #666; + font-style: italic; +} + .activity-chart{ padding: 15px; } diff --git a/static/asteroid.lass b/static/asteroid.lass index 5bd0449..1c5b0ac 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1401,6 +1401,80 @@ (.avatar-overlay :opacity "1")) + ;; Track Request styling + (.request-panel + :background "rgba(0, 255, 0, 0.05)" + :border "1px solid #333" + :border-radius "8px" + :padding "20px" + :margin-top "20px") + + (.request-description + :color "#888" + :margin-bottom "15px") + + (.request-form + :display "flex" + :flex-direction "column" + :gap "10px") + + (.request-input + :background "#1a1a1a" + :border "1px solid #333" + :border-radius "4px" + :padding "10px" + :color "#00cc00" + :font-size "1em") + + ((:and .request-input :focus) + :border-color "#00cc00" + :outline "none") + + (.request-status + :padding "10px" + :border-radius "4px" + :margin-top "10px" + :text-align "center") + + ((:and .request-status .success) + :background "rgba(0, 255, 0, 0.2)" + :color "#00ff00") + + ((:and .request-status .error) + :background "rgba(255, 0, 0, 0.2)" + :color "#ff6b6b") + + ((:and .request-status .info) + :background "rgba(0, 150, 255, 0.2)" + :color "#66b3ff") + + (.recent-requests + :margin-top "20px" + :border-top "1px solid #333" + :padding-top "15px" + + (h4 + :color "#888" + :margin-bottom "10px")) + + (.request-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "8px 0" + :border-bottom "1px solid #222") + + (.request-title + :color "#00cc00") + + (.request-by + :color "#666" + :font-size "0.9em") + + (.no-requests + :color "#666" + :font-style "italic") + ;; Activity chart styling (.activity-chart :padding "15px" diff --git a/static/avatars/5.png b/static/avatars/5.png new file mode 100644 index 0000000..95e48be Binary files /dev/null and b/static/avatars/5.png differ diff --git a/template/front-page.ctml b/template/front-page.ctml index 77994cd..a34913c 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -125,6 +125,26 @@

Loading...

+ + +
+

🎵 Request a Track

+

Want to hear something specific? Submit a request!

+
+ + + +
+ + + +
+

Recently Played Requests

+
+

No recent requests

+
+
+