1
0
Fork 0
mirror of https://github.com/heyarne/airsonic-ui.git synced 2026-05-06 10:23:39 +02:00

Merge feature/search

Squashed commit of the following:

commit 8a19df91f8daa1b791d40cc910947c94355a8d0d
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue Aug 28 16:06:35 2018 +0200

    Implement search UI (closes #19)

commit bf661dd25ec9f1d5569df88a8a87f94c1bc1b317
Author: Arne Schlüter <arne@schlueter.is>
Date:   Tue Aug 28 11:09:46 2018 +0200

    Re-add subscription for single endpoint and move helpers to a different location
This commit is contained in:
Arne Schlüter 2018-08-28 16:07:45 +02:00
commit 7653af5dd1
22 changed files with 236 additions and 49 deletions

View file

@ -1,5 +1,6 @@
(ns airsonic-ui.api.helpers
(:require [clojure.string :as str]))
(:require [clojure.string :as str]
[clojure.set :as set]))
(def default-params {:f "json"
:c "airsonic-ui-cljs"
@ -41,11 +42,22 @@
"Retrieves the actual response body"
[response]
(if (is-error? response)
(let [error (:error response)]
(throw (->exception response)))
(throw (->exception response))
(unwrap-response* response)))
(defn error-msg
[exception-info]
(let [{:keys [code message]} (ex-data exception-info)]
(str "Error " code ": " message)))
(defn content-type
"Given some piece of data returned by the api, returns a keyword that
describes what we look at"
[data]
(keyword :content-type
(condp set/subset? (set (keys data))
#{:path} :song
#{:artistId :name :songCount :artist} :album
#{:id :name :albumCount} :artist
:unknown)))

View file

@ -2,6 +2,13 @@
(:require [clojure.string :as str]
[re-frame.core :refer [reg-sub]]))
(defn response-for
"Returns the cached response for a single endpoint"
[db [_ endpoint params]]
(get-in db [:api/responses [endpoint params]]))
(reg-sub :api/response-for response-for)
(defn endpoint->kw
"Given an endpoint like `getAlbumList2`, returns a cleaned keyword like
`:album-list``.

View file

@ -2,7 +2,7 @@
"Implements playlist queues that support different kinds of repetition and
song ordering."
(:refer-clojure :exclude [peek])
(:require [airsonic-ui.utils.helpers :refer [find-where]]))
(:require [airsonic-ui.helpers :refer [find-where]]))
(defrecord Playlist [queue playback-mode repeat-mode]
cljs.core/ICounted

View file

@ -0,0 +1,15 @@
(ns airsonic-ui.components.search.events
(:require [re-frame.core :refer [reg-event-fx reg-event-db]]
[airsonic-ui.routes :as routes]))
(reg-event-db
;; this is called on navigation and handled in routes.cljs; the reason is that
;; when we're navigating to search?query=foo we don't have the term in our db.
:search/restore-term-from-param
(fn [db [_ term]]
(assoc-in db [:search :term] term)))
(reg-event-fx
:search/do-search
(fn do-search [fx [_ term]]
{:dispatch [:routes/do-navigation [::routes/search {} {:query term}]]}))

View file

@ -0,0 +1,5 @@
(ns airsonic-ui.components.search.subs
(:require [re-frame.core :refer [reg-sub subscribe]]))
(reg-sub :search/current-term (fn current-term [db _]
(get-in db [:search :term])))

View file

@ -0,0 +1,67 @@
(ns airsonic-ui.components.search.views
(:require [clojure.pprint :refer [pprint]]
[re-frame.core :refer [dispatch subscribe]]
[goog.functions :refer [debounce]]
[airsonic-ui.routes :as routes :refer [url-for]]
[airsonic-ui.views.song :as song]
[airsonic-ui.views.cover :refer [card]]))
(defn form []
(let [search-term @(subscribe [:search/current-term])
throttled-search (debounce #(dispatch [:search/do-search (.. % -target -value)]) 100)]
(fn []
[:form {:on-submit #(.preventDefault %)}
[:div.feld>p.control
[:input.input {:on-change (fn [e]
;; the event might be gone when we the dispatched
;; function is fired, we need to persist it
(.persist e)
(throttled-search e))
:default-value search-term
:placeholder "Search"}]]])))
(defn artist-results [{:keys [artist]}]
[:div.columns.is-multiline.is-mobile
(for [[idx artist] (map-indexed vector artist)]
(let [url #(url-for ::routes/artist-view (select-keys % [:id]))]
^{:key idx} [:div.column.is-2
[card artist
:url-fn url
:content [:div>a
{:href (url artist), :title (:name artist)}
(:name artist)]]]))])
(defn album-results [{:keys [album]}]
[:div.columns.is-multiline.is-mobile
(for [[idx album] (map-indexed vector album)]
(let [url #(url-for ::routes/album-view (select-keys % [:id]))
title (str (:name album) " (" (:artist album) ")")]
^{:key idx} [:div.column.is-2 [card album
:url-fn url
:content [:div>a
{:href (url album), :title title}
title]]]))])
(defn song-results [{:keys [song]}]
[song/listing song])
(defn results [{:keys [search]}]
(let [term @(subscribe [:search/current-term])]
[:div
[:h2.title (str "Search results for \"" term "\"")]
(if (empty? search)
[:p "The server returned no results."]
[:div.content
(when-not (empty? (:artist search))
[:section.section.is-small
[:h3.subtitle.is-5 "Artists"]
[artist-results search]])
(when-not (empty? (:album search))
[:section.section.is-small
[:h3.subtitle.is-5 "Albums"]
[album-results search]])
(when-not (empty? (:song search))
[:section.section.is-small
[:h3.subtitle.is-5 "Songs"]
[song-results search]])])
[:pre (with-out-str (pprint search))]]))

View file

@ -10,6 +10,8 @@
[airsonic-ui.audio.core]
[airsonic-ui.api.events]
[airsonic-ui.api.subs]
[airsonic-ui.components.search.events]
[airsonic-ui.components.search.subs]
[airsonic-ui.events :as events]
[airsonic-ui.views :as views]
[airsonic-ui.config :as config]))

View file

@ -1,4 +1,4 @@
(ns airsonic-ui.utils.helpers
(ns airsonic-ui.helpers
"Assorted helper functions"
(:require [re-frame.core :as rf]))
@ -16,3 +16,9 @@
(fn [e]
(.preventDefault e)
(rf/dispatch ev)))
(defn add-classes
"Adds one or more classes to a hiccup keyword"
[elem & classes]
(keyword (apply str (name elem) (->> (filter identity classes)
(map #(str "." (name %)))))))

View file

@ -5,11 +5,12 @@
(def default-route ::login)
(def router
(defonce router
(r/router [["/" ::login]
["/main" ::main]
["/artist/:id" ::artist-view]
["/album/:id" ::album-view]]))
["/album/:id" ::album-view]
["/search" ::search]]))
;; use this in views to construct a url
(defn url-for
@ -17,7 +18,7 @@
([k params] (str "#" (r/resolve router k params))))
;; which routes need valid login credentials?
(def protected-routes #{::main ::artist-view ::album-view})
(def protected-routes #{::main ::artist-view ::album-view ::search})
;; which data should be requested for which route? can either be a vector or a function returning a vector
@ -42,6 +43,11 @@
[route-id params query]
[:api/request "getAlbum" (select-keys params [:id])])
(defmethod -route-events ::search
[route-id params query]
[[:search/restore-term-from-param (:query query)]
[:api/request "search3" query]])
;; shouldn't need to change anything below
(defn- n-events?
@ -91,8 +97,9 @@
credentials'(get-in context [:coeffects :db :credentials])]
(println "calling do-navigation with" route credentials')
(reset! credentials credentials')
(println "context" context)
(apply r/navigate! router route)
context))))
(dissoc context :event)))))
(re-frame/reg-event-fx :routes/do-navigation do-navigation (fn [& _] nil))

View file

@ -3,13 +3,15 @@
[airsonic-ui.routes :as routes :refer [url-for]]
[airsonic-ui.events :as events]
[airsonic-ui.subs :as subs]
[airsonic-ui.helpers :refer [add-classes]]
[airsonic-ui.views.notifications :refer [notification-list]]
[airsonic-ui.views.breadcrumbs :refer [breadcrumbs]]
[airsonic-ui.views.audio-player :refer [audio-player]]
[airsonic-ui.views.login :refer [login-form]]
[airsonic-ui.views.album :as album]
[airsonic-ui.views.song :as song]))
[airsonic-ui.views.song :as song]
[airsonic-ui.components.search.views :as search]))
;; TODO: Find better names and places for these.
@ -31,6 +33,7 @@
(defn sidebar [user]
[:aside.menu.section
[search/form]
[:p.menu-label "Music"]
[:ul.menu-list
[:li [:a "By artist"]]
@ -56,20 +59,21 @@
[:main.columns
[:div.column.is-2.sidebar
[sidebar user]]
[:div.column
[:div.column.is-10
[:section.section
[breadcrumbs content]
(case route-id
::routes/main [most-recent content]
::routes/artist-view [artist-detail content]
::routes/album-view [album-detail content])]]]
::routes/album-view [album-detail content]
::routes/search [search/results content])]]]
[audio-player]]))
(defn main-panel []
(let [notifications @(subscribe [::subs/notifications])
is-booting? @(subscribe [::subs/is-booting?])
[route-id params query] @(subscribe [:routes/current-route])]
[:div
[(add-classes :div route-id)
[notification-list notifications]
(if is-booting?
[:div.app-loading>div.loader]

View file

@ -1,23 +1,23 @@
(ns airsonic-ui.views.album
(:require [airsonic-ui.routes :as routes :refer [url-for]]
[airsonic-ui.views.cover :refer [cover]]))
[airsonic-ui.views.cover :refer [cover card]]))
(defn preview [album]
(let [{:keys [artist artistId name coverArt id]} album]
[:article.card.album-preview
[:div.card-image
[:a {:href (url-for ::routes/album-view {:id id})} [cover album 256]]]
[:div.card-content
;; link to album
[:div.title.is-5
[:a {:href (url-for ::routes/album-view {:id id})} name]]
;; link to artist page
[:div.subtitle.is-6 [:a {:href (url-for ::routes/artist-view {:id artistId})} artist]]]]))
(let [{:keys [artist artistId name id]} album]
[card album
:url-fn #(url-for ::routes/album-view {:id id})
:content [:div
;; link to album
[:div.title.is-5
[:a {:href (url-for ::routes/album-view {:id id})
:title name} name]]
;; link to artist page
[:div.subtitle.is-6 [:a {:href (url-for ::routes/artist-view {:id artistId})
:title artist} artist]]]]))
(defn listing [albums]
;; always show 5 in a row
[:div
[:div.columns.is-multiline.is-mobile
(for [[idx album] (map-indexed vector albums)]
^{:key idx} [:div.column.is-one-fifth-desktop.is-one-quarter-tablet.is-half-mobile
[preview album]])]])
[:div.columns.is-multiline.is-mobile
(for [[idx album] (map-indexed vector albums)]
^{:key idx} [:div.column.is-one-fifth-desktop.is-one-quarter-tablet.is-half-mobile
[preview album]])])

View file

@ -1,6 +1,6 @@
(ns airsonic-ui.views.audio-player
(:require [re-frame.core :refer [subscribe]]
[airsonic-ui.utils.helpers :refer [dispatch]]
[airsonic-ui.helpers :refer [add-classes dispatch]]
[airsonic-ui.events :as events]
[airsonic-ui.views.cover :refer [cover]]
[airsonic-ui.views.icon :refer [icon]]))
@ -25,12 +25,6 @@
[icon icon-glyph]])
buttons))])
(defn- add-classes
"Adds one or more classes to a hiccup keyword"
[elem & classes]
(keyword (apply str (name elem) (->> (filter identity classes)
(map #(str "." (name %)))))))
(defn- toggle-shuffle [playback-mode]
(dispatch [::events/set-playback-mode (if (= playback-mode :shuffled)
:linear :shuffled)]))

View file

@ -5,12 +5,13 @@
;; hierarchy no matter how you came to the url. They should allow easy
;; navigation upwards that hierarchy (e.g. album -> artist)
(defn content-type
(defn page-type
"Helper to see what kind of view we're currently dealing with"
[content]
(case (set (keys content))
#{:artist :artist-info} :artist
#{:album} :album
#{:search} :search
:other-content))
(defn- bulma-breadcrumbs [& items]
@ -20,7 +21,7 @@
[:li {:key idx} [:a {:href href} label]])
[:li.is-active>a (last items)]]])
(defmulti breadcrumbs content-type)
(defmulti breadcrumbs page-type)
(defmethod breadcrumbs :default [content]
[bulma-breadcrumbs "Start"])
@ -35,3 +36,8 @@
[(url-for ::routes/main) "Start"]
[(url-for ::routes/artist-view {:id (:artistId album)}) (:artist album)]
(:name album)])
(defmethod breadcrumbs :search [_]
[bulma-breadcrumbs
[(url-for ::routes/main) "Start"]
"Search"])

View file

@ -65,3 +65,8 @@
[:img {:src original
:srcSet (str original ", " retina " 2x")}]
[missing-cover item size])]))
(defn card [item & {:keys [url-fn content size] :or {size 256}}]
[:article.card.preview-card
[:div.card-image [:a {:href (url-fn item)} [cover item size]]]
[:div.card-content content]])

View file

@ -1,4 +1,4 @@
(ns airsonic-ui.views.icon)
(defn icon [glyph]
(defn icon [glyph & extra]
[:span.icon [:span.oi {:data-glyph (name glyph)}]])

View file

@ -1,5 +1,5 @@
(ns airsonic-ui.views.song
(:require [airsonic-ui.utils.helpers :refer [dispatch]]
(:require [airsonic-ui.helpers :refer [dispatch]]
[airsonic-ui.events :as events]
[airsonic-ui.routes :as routes :refer [url-for]]
[airsonic-ui.views.icon :refer [icon]]))
@ -7,9 +7,9 @@
(defn item [songs song idx]
(let [artist-id (:artistId song)]
[:div
[:a
(when artist-id {:href (url-for ::routes/artist-view {:id artist-id})})
(:artist song)]
(if artist-id
[:a {:href (url-for ::routes/artist-view {:id artist-id})} (:artist song)]
(:artist song))
" - "
[:a
{:href "#" :on-click (dispatch [::events/play-songs songs idx])}

View file

@ -67,7 +67,9 @@
.missing-cover
display: block
.album-preview
// preview card for album or artist listings
.preview-card
.card-content > div,
.title,
.subtitle
overflow: hidden
@ -79,6 +81,7 @@
height: auto
max-width: 256px
max-height: 256px
margin: 0
// occurs in album detail view
.table
@ -107,3 +110,12 @@
.loading-spinner
.icon
animation: 1s infinite you-spin-my-head-right-round
// route specific styling
.search
.content .section
padding: 1.5rem 0
.preview-card .card-content
padding: 0.375rem 0.75rem 0.75rem

View file

@ -1,7 +1,7 @@
(ns airsonic-ui.api.helpers-test
(:require [cljs.test :refer [deftest testing is]]
[clojure.string :as str]
[airsonic-ui.fixtures :refer [responses]]
[airsonic-ui.fixtures :as fixtures :refer [responses]]
[airsonic-ui.api.helpers :as api]))
(defn- url
@ -20,6 +20,12 @@
(is (string? (re-find #"f=json" (fixtures :default-url))))
(is (string? (re-find #"v=1\.15\.0" (fixtures :default-url))))))
(deftest parameter-encoding
(testing "Should escape url parameters"
(let [query "äöüß"
encoded-str (js/encodeURIComponent query)]
(is (str/includes? (api/url "http://localhost" "search3" {:query query}) encoded-str)))))
(deftest song-urls
(testing "Should construct the url based on a song's id"
(let [song {:id 1234}]
@ -46,7 +52,7 @@
(try
(api/unwrap-response error-response)
(catch ExceptionInfo e
(= (:error error-response) (ex-data e)))))))
(is (= (get-in error-response [:subsonic-response :error]) (ex-data e))))))))
(deftest error-recognition
(testing "Should detect error responses"
@ -55,3 +61,13 @@
(testing "Should pass on good responses"
(is (false? (api/is-error? (:ok responses))))
(is (false? (api/is-error? (:auth-success responses))))))
(deftest content-type
(testing "Should detect whether the data we look at represents a song"
(is (= :content-type/song (api/content-type fixtures/song))))
(testing "Should detect whether the data we look at represents an artist"
(is (= :content-type/artist (api/content-type fixtures/artist)))
(is (= :content-type/artist (api/content-type (dissoc fixtures/artist :coverArt)))))
(testing "Should detect whether the data we look at represents an album"
(is (= :content-type/album (api/content-type fixtures/album)))
(is (= :content-type/album (api/content-type (dissoc fixtures/album :coverArt))))))

View file

@ -4,6 +4,12 @@
(enable-console-print!)
(deftest single-response
(testing "Should return the response for a specified endpoint"
(let [db {:api/responses {["search2" {:query "query term"}] :result}}]
(is (= :result (sub/response-for db [:api/response-for "search2" {:query "query term"}])))
(is (nil? (sub/response-for db [:api/response-for "search2" {:query "another query term"}]))))))
(deftest endpoint-keywordification
(testing "Should strip prefixes"
(is (= :artist-info (sub/endpoint->kw "getArtistInfo")))

View file

@ -1,7 +1,7 @@
(ns airsonic-ui.audio.playlist-test
(:require [cljs.test :refer [deftest testing is]]
[airsonic-ui.audio.playlist :as playlist]
[airsonic-ui.utils.helpers :refer [find-where]]
[airsonic-ui.helpers :refer [find-where]]
[airsonic-ui.fixtures :as fixtures]
[airsonic-ui.test-helpers :as helpers]
[debux.cs.core :refer-macros [dbg]]))

View file

@ -21,6 +21,20 @@
:error {:code 40
:message "Wrong username or password."}}}})
(def artist
{:id "499", :name "Tomemitsu", :coverArt "ar-497", :albumCount 1})
(def album
{:artistId "258",
:name "Tocotronic",
:songCount 26,
:created "2017-12-31T08:18:45.000Z",
:duration 7383,
:artist "Tocotronic",
:year 2015,
:id "439",
:coverArt "al-439"})
(def song
{:artistId 42,
:path "DJ Koze/DJ Koze - Reincarnations Part 2, The Remix Chapter 2009-2014/14. Apparat - Black Water (DJ Koze Remix).mp3",

View file

@ -1,6 +1,6 @@
(ns airsonic-ui.utils.helpers-test
(ns airsonic-ui.helpers-test
(:require [cljs.test :refer [deftest testing is]]
[airsonic-ui.utils.helpers :as helpers]))
[airsonic-ui.helpers :as helpers]))
(deftest find-where
(testing "Finds the correct item and index"
@ -12,3 +12,12 @@
:bar false})))))
(testing "Returns nil when nothing is found"
(is (nil? (helpers/find-where (partial = 2) (range 2))))))
(deftest add-classes
(testing "Should add classes to a simple hiccup keyword"
(is (= :div.foo (helpers/add-classes :div :foo)))
(is (= :div.bar.bar (helpers/add-classes :div.bar :bar)))
(is (= :div.foo.bar (helpers/add-classes :div.foo :bar))))
(testing "Should add classes to the innermost child of a nested hiccup element"
(is (= :p>input.input (helpers/add-classes :p>input :input)))
(is (= :div.field>p>input.input.has-background-red (helpers/add-classes :div.field>p>input.input :has-background-red)))))