1
0
Bläddra i källkod

feat(frontend): Modularize components and add pagination

Miguel Ángel Moreno 2 år sedan
förälder
incheckning
b5404ac06a

+ 74 - 0
src/frontend/tau/components/items.cljs

@@ -0,0 +1,74 @@
+(ns tau.components.items
+  (:require
+   [reitit.frontend.easy :as rfe]))
+
+(defn stream-item
+  [id {:keys [url name thumbnail-url upload-author upload-url
+              upload-avatar upload-date short-description
+              duration view-count uploaded verified?]}]
+  [:div.w-56.h-66.my-2 {:key id}
+   [:div.px-5.py-2.m-2.flex.flex-col.max-w-full.min-h-full.max-h-full
+    [:a.overflow-hidden {:href (rfe/href :tau.routes/stream nil {:url url}) :title name}
+     [:div.flex.py-3.box-border.h-28
+      [:div.relative.min-w-full
+       [:img.rounded.object-cover.max-h-full.min-w-full {:src thumbnail-url}]
+       [:div.rounded.p-2.absolute {:style {:bottom 5 :right 5 :background "rgba(0,0,0,.7)"}}
+        [:p {:style {:fontSize "14px"}}
+         (let [duration (js/Date. (* duration 1000))
+               slice (if (> (.getHours duration) 1)
+                       #(.slice % 11 19)
+                       #(.slice % 14 19))]
+           (-> duration (.toISOString) slice))]]]]
+     [:div.my-2
+      [:h1.line-clamp-2.my-1 name]]
+     [:a {:href (rfe/href :tau.routes/channel nil {:url upload-url}) :title upload-author}
+      [:div.flex.items-center.my-2
+       [:h1.line-clamp-1.text-gray-300.font-bold.pr-2 upload-author]
+       (when verified?
+         [:i.fa-solid.fa-circle-check])]]
+     [:div.flex.my-1.justify-between
+      [:p (if (-> upload-date js/Date.parse js/isNaN)
+            upload-date
+            (-> upload-date
+                js/Date.parse
+                js/Date.
+                .toDateString))]
+      [:div.flex.items-center.h-full.pl-2
+       [:i.fa-solid.fa-eye.text-xs]
+       [:p.pl-1.5 (.toLocaleString view-count)]]]]]])
+
+(defn channel-item
+  [id {:keys [url name thumbnail-url description subscriber-count stream-count verified?]}]
+  [:div.w-56.h-64.my-2 {:key id}
+   [:div.px-5.py-2.m-2.flex.flex-col.max-w-full.min-h-full.max-h-full
+    [:a.overflow-hidden {:href (rfe/href :tau.routes/channel nil {:url url}) :title name}
+     [:div.flex.min-w-full.py-3.box-border.h-28
+      [:div.min-w-full
+       [:img.rounded.object-cover.max-h-full.min-w-full {:src thumbnail-url}]]]
+     [:div.overflow-hidden
+      [:div.flex.items-center.my-2
+       [:h1.line-clamp-1.text-gray-300.font-bold.pr-2 name]
+       (when verified?
+         [:i.fa-solid.fa-circle-check])]
+      [:div.flex.items-center
+       [:i.fa-solid.fa-users.text-xs]
+       [:p.mx-2 subscriber-count]]
+      [:div.flex.items-center
+       [:i.fa-solid.fa-video.text-xs]
+       [:p.mx-2 stream-count]]]]]])
+
+(defn playlist-item
+  [id {:keys [url name thumbnail-url upload-author stream-count]}]
+  [:div.w-56.h-64.my-2 {:key id}
+   [:div.px-5.py-2.m-2.flex.flex-col.max-w-full.min-h-full.max-h-full
+    [:a.overflow-hidden {:href (rfe/href :tau.routes/playlist nil {:url url}) :title name}
+     [:div.flex.min-w-full.py-3.box-border.h-28
+      [:div.min-w-full
+       [:img.rounded.object-cover.max-h-full.min-w-full {:src thumbnail-url}]]]
+     [:div.overflow-hidden
+      [:h1.line-clamp-2 name]
+      [:h1.text-gray-300.font-bold upload-author]
+      [:p (condp >= stream-count
+            0 "No streams"
+            1 (str stream-count " stream")
+            (str stream-count " streams"))]]]]])

+ 16 - 0
src/frontend/tau/components/loading.cljs

@@ -0,0 +1,16 @@
+(ns tau.components.loading
+  (:require
+   [re-frame.core :as rf]))
+
+(defn page-loading-icon
+  [service-color]
+  [:div.w-full.flex.justify-center.items-center.flex-auto
+   [:i.fas.fa-circle-notch.fa-spin.text-8xl
+    {:style {:color service-color}}]])
+
+(defn pagination-loading-icon
+  [service-color loading?]
+  [:div.w-full.flex.items-center.justify-center.py-4
+   {:class (when-not loading? "invisible")}
+   [:i.fas.fa-circle-notch.fa-spin.text-2xl
+    {:style {:color service-color}}]])

+ 13 - 0
src/frontend/tau/components/navigation.cljs

@@ -0,0 +1,13 @@
+(ns tau.components.navigation
+  (:require
+   [re-frame.core :as rf]
+   [tau.events :as events]))
+
+(defn back-button []
+  (let [service-color @(rf/subscribe [:service-color])]
+    [:div.flex {:class "w-4/5"}
+     [:button.p-2
+      {:on-click #(rf/dispatch [::events/history-back])}
+      [:i.fa-solid.fa-chevron-left
+       {:style {:color service-color}}]
+      [:span " Back"]]]))

+ 3 - 4
src/frontend/tau/core.cljs

@@ -1,9 +1,8 @@
 (ns tau.core
   (:require
-   [day8.re-frame.http-fx]
    [reagent.dom :as rdom]
    [re-frame.core :as rf]
-   [tau.events]
+   [tau.events :as events]
    [tau.routes :as routes]
    [tau.subs]
    [tau.views :as views]))
@@ -11,12 +10,12 @@
 (defn ^:dev/after-load mount-root
   []
   (rf/clear-subscription-cache!)
+  (routes/start-routes!)
   (rdom/render
    [views/app]
    (.querySelector js/document "#app")))
 
 (defn ^:export init
   []
-  (routes/start-routes!)
-  (rf/dispatch-sync [:initialize-db])
+  (rf/dispatch-sync [::events/initialize-db])
   (mount-root))

+ 177 - 31
src/frontend/tau/events.cljs

@@ -1,81 +1,227 @@
 (ns tau.events
   (:require
+   [day8.re-frame.http-fx]
    [re-frame.core :as rf]
+   [reitit.frontend.easy :as rfe]
    [tau.api :as api]))
 
 (rf/reg-event-db
- :initialize-db
+ ::initialize-db
  (fn [_ _]
    {:global-search ""
     :service-id 0
+    :service-color "#cc0000"
     :stream {}
     :search-results []
     :services []
-    :current-match nil}))
+    :current-match nil
+    :page-scroll 0}))
 
-(rf/reg-event-db
- :navigated
- (fn [db [_ new-match]]
-   (assoc db :current-match new-match)))
+(rf/reg-fx
+ ::scroll-to-top
+ (fn [_]
+   (.scrollTo js/window #js {"top" 0 "behavior" "smooth"})))
+
+(rf/reg-fx
+ ::history-back!
+ (fn [_]
+   (.back js/window.history)))
+
+(rf/reg-event-fx
+ ::history-back
+ (fn [_ _]
+   {::history-back! nil}))
+
+(rf/reg-event-fx
+ ::navigated
+ (fn [{:keys [db]} [_ new-match]]
+   {::scroll-to-top nil
+    :db (assoc db :current-match new-match)
+    :fx [[:dispatch [::reset-page-scroll]]]}))
+
+(rf/reg-event-fx
+ ::navigate
+ (fn [_ [_ route]]
+   {::navigate! route}))
+
+(rf/reg-fx
+ ::navigate!
+ (fn [{:keys [name params query]}]
+   (rfe/push-state name params query)))
 
 (rf/reg-event-db
- :bad-response
+ ::bad-response
  (fn [db [_ res]]
    (assoc db :http-response (get-in res [:response :error]))))
 
 (rf/reg-event-db
- :change-global-search
+ ::change-global-search
  (fn [db [_ res]]
    (assoc db :global-search res)))
 
 (rf/reg-event-db
- :change-service-id
+ ::change-service-color
+ (fn [db [_ id]]
+   (assoc db :service-color
+          (case id
+            0 "#cc0000"
+            1 "#ff7700"
+            2 "#333333"
+            3 "#F2690D"
+            4 "#629aa9"))))
+
+(rf/reg-event-fx
+ ::change-service-id
+ (fn [{:keys [db]} [_ id]]
+   {:db (assoc db :service-id id)
+    :fx [[:dispatch [::change-service-color id]]]}))
+
+(rf/reg-event-db
+ ::load-paginated-channel-results
  (fn [db [_ res]]
-   (assoc db :service-id res)))
+   (-> db
+       (update-in [:channel :related-streams] #(apply conj %1 %2)
+                  (:related-streams (js->clj res :keywordize-keys true)))
+       (assoc-in [:channel :next-page]
+                 (:next-page (js->clj res :keywordize-keys true)))
+       (assoc :show-pagination-loading false))))
 
 (rf/reg-event-fx
- :switch-to-global-player
+ ::scroll-channel-pagination
+ (fn [{:keys [db]} [_ uri next-page-url]]
+   (assoc
+    (api/get-request
+     (str "/api/channels/" (js/encodeURIComponent uri) )
+     [::load-paginated-channel-results] [::bad-response]
+     {:nextPage (js/encodeURIComponent next-page-url)})
+    :db (assoc db :show-pagination-loading true))))
+
+(rf/reg-event-db
+ ::load-paginated-search-results
+ (fn [db [_ res]]
+   (-> db
+       (update-in [:search-results :items] #(apply conj %1 %2)
+                  (:items (js->clj res :keywordize-keys true)))
+       (assoc-in [:search-results :next-page]
+                 (:next-page (js->clj res :keywordize-keys true)))
+       (assoc :show-pagination-loading false))))
+
+(rf/reg-event-db
+ ::reset-page-scroll
+ (fn [db _]
+   (assoc db :page-scroll 0)))
+
+(rf/reg-event-db
+ ::page-scroll
+ (fn [db _]
+   (assoc db :page-scroll (+ (.-scrollY js/window) (.-innerHeight js/window)))))
+
+(rf/reg-event-fx
+ ::scroll-search-pagination
+ (fn [{:keys [db]} [_ query id next-page-url]]
+   (assoc
+    (api/get-request
+     (str "/api/services/" id "/search")
+     [::load-paginated-search-results] [::bad-response]
+     {:q query
+      :nextPage (js/encodeURIComponent next-page-url)})
+    :db (assoc db :show-pagination-loading true))))
+
+(rf/reg-event-fx
+ ::switch-to-global-player
  (fn [{:keys [db]} [_ res]]
    {:db (assoc db :show-global-player true)
-    :dispatch [:change-global-search res]}))
+    :fx [[:dispatch [::change-global-search res]]]}))
 
 (rf/reg-event-db
- :load-services
+ ::load-services
  (fn [db [_ res]]
-   (assoc db :services (js->clj res :keywordize-keys true)
-          :show-loading false)))
+   (assoc db :services (js->clj res :keywordize-keys true))))
 
 (rf/reg-event-fx
- :get-services
+ ::get-services
  (fn [{:keys [db]} _]
+   (api/get-request "/api/services" [::load-services] [::bad-response])))
+
+(rf/reg-event-db
+ ::load-kiosks
+ (fn [db [_ res]]
+   (assoc db :kiosks (js->clj res :keywordize-keys true))))
+
+(rf/reg-event-fx
+ ::get-kiosks
+ (fn [{:keys [db]} [_ id]]
+   (api/get-request (str "/api/services/" id "/kiosks") [::load-kiosks] [::bad-response])))
+
+(rf/reg-event-db
+ ::load-kiosk
+ (fn [db [_ res]]
+   (assoc db :kiosk (js->clj res :keywordize-keys true)
+          :show-page-loading false)))
+
+(rf/reg-event-fx
+ ::get-kiosk
+ (fn [{:keys [db]} [_ {:keys [service-id kiosk-id]}]]
    (assoc
-    (api/get-request "/api/services" [:load-services] [:bad-response])
-    :db (assoc db :show-loading true))))
+    (api/get-request (str "/api/services/" service-id "/kiosks/"
+                          (js/encodeURIComponent kiosk-id))
+                     [::load-kiosk] [::bad-response])
+    :db (assoc db :show-page-loading true))))
 
 (rf/reg-event-db
- :load-stream
+ ::load-stream
  (fn [db [_ res]]
    (assoc db :stream (js->clj res :keywordize-keys true)
-          :show-loading false)))
+          :show-page-loading false)))
+
+(rf/reg-event-fx
+ ::get-stream
+ (fn [{:keys [db]} [_ uri]]
+   (assoc
+    (api/get-request (str "/api/streams/" (js/encodeURIComponent uri))
+                     [::load-stream] [::bad-response])
+    :db (assoc db :show-page-loading true))))
+
+(rf/reg-event-db
+ ::load-channel
+ (fn [db [_ res]]
+   (assoc db :channel (js->clj res :keywordize-keys true)
+          :show-page-loading false)))
+
+(rf/reg-event-fx
+ ::get-channel
+ (fn [{:keys [db]} [_ uri]]
+   (assoc
+    (api/get-request
+     (str "/api/channels/" (js/encodeURIComponent uri))
+     [::load-channel] [::bad-response])
+    :db (assoc db :show-page-loading true))))
+
+(rf/reg-event-db
+ ::load-playlist
+ (fn [db [_ res]]
+   (assoc db :playlist (js->clj res :keywordize-keys true)
+          :show-page-loading false)))
 
 (rf/reg-event-fx
- :get-stream
+ ::get-playlist
  (fn [{:keys [db]} [_ uri]]
    (assoc
-    (api/get-request "/api/stream" [:load-stream] [:bad-response] {:url uri})
-    :db (assoc db :show-loading true))))
+    (api/get-request (str "/api/playlists/" (js/encodeURIComponent uri))
+                     [::load-playlist] [::bad-response])
+    :db (assoc db :show-page-loading true))))
 
 (rf/reg-event-db
- :load-search-results
+ ::load-search-results
  (fn [db [_ res]]
    (assoc db :search-results (js->clj res :keywordize-keys true)
-          :show-loading false)))
+          :show-page-loading false)))
 
 (rf/reg-event-fx
- :get-search-results
- (fn [{:keys [db]} [_ {:keys [id query]}]]
+ ::get-search-results
+ (fn [{:keys [db]} [_ {:keys [service-id query]}]]
    (assoc
-    (api/get-request "/api/search"
-                     [:load-search-results] [:bad-response]
-                     {:serviceId id :q query})
-    :db (assoc db :show-loading true))))
+    (api/get-request (str "/api/services/" service-id "/search")
+                     [::load-search-results] [::bad-response]
+                     {:q query})
+    :db (assoc db :show-page-loading true))))

+ 27 - 4
src/frontend/tau/routes.cljs

@@ -4,7 +4,11 @@
    [reitit.frontend.easy :as rfe]
    [reitit.frontend.controllers :as rfc]
    [re-frame.core :as rf]
+   [tau.events :as events]
+   [tau.views.channel :as channel]
    [tau.views.home :as home]
+   [tau.views.kiosk :as kiosk]
+   [tau.views.playlist :as playlist]
    [tau.views.search :as search]
    [tau.views.stream :as stream]))
 
@@ -16,14 +20,33 @@
                 :name ::search
                 :controllers [{:parameters {:query [:q :serviceId]}
                                :start (fn [parameters]
-                                        (rf/dispatch [:get-search-results
-                                                      {:id (-> parameters :query :serviceId)
+                                        (rf/dispatch [::events/change-service-id
+                                                      (js/parseInt (-> parameters :query :serviceId))])
+                                        (rf/dispatch [::events/get-search-results
+                                                      {:service-id (-> parameters :query :serviceId)
                                                        :query (-> parameters :query :q)}]))}]}]
     ["/stream" {:view stream/stream
                 :name ::stream
                 :controllers [{:parameters {:query [:url]}
                                :start (fn [parameters]
-                                        (rf/dispatch [:get-stream (-> parameters :query :url)]))}]}]]))
+                                        (rf/dispatch [::events/get-stream (-> parameters :query :url)]))}]}]
+    ["/channel" {:view channel/channel
+                 :name ::channel
+                 :controllers [{:parameters {:query [:url]}
+                                :start (fn [parameters]
+                                         (rf/dispatch [::events/get-channel (-> parameters :query :url)]))}]}]
+    ["/playlist" {:view playlist/playlist
+                  :name ::playlist
+                  :controllers [{:parameters {:query [:url]}
+                                 :start (fn [parameters]
+                                          (rf/dispatch [::events/get-playlist (-> parameters :query :url)]))}]}]
+    ["/kiosk" {:view kiosk/kiosk
+               :name ::kiosk
+               :controllers [{:parameters {:query [:kioskId :serviceId]}
+                              :start (fn [parameters]
+                                       (rf/dispatch [::events/get-kiosk
+                                                     {:service-id (-> parameters :query :serviceId)
+                                                      :kiosk-id (-> parameters :query :kioskId)}]))}]}]]))
 
 (defn on-navigate
   [new-match]
@@ -31,7 +54,7 @@
     (when new-match
       (let [controllers (rfc/apply-controllers (:controllers @old-match) new-match)
             match (assoc new-match :controllers controllers)]
-        (rf/dispatch [:navigated match])))))
+        (rf/dispatch [::events/navigated match])))))
 
 (defn start-routes!
   []

+ 35 - 0
src/frontend/tau/subs.cljs

@@ -12,6 +12,11 @@
  (fn [db _]
    (:stream db)))
 
+(rf/reg-sub
+ :channel
+ (fn [db _]
+   (:channel db)))
+
 (rf/reg-sub
  :global-search
  (fn [db _]
@@ -22,16 +27,36 @@
  (fn [db _]
    (:service-id db)))
 
+(rf/reg-sub
+ :service-color
+ (fn [db _]
+   (:service-color db)))
+
 (rf/reg-sub
  :services
  (fn [db _]
    (:services db)))
 
+(rf/reg-sub
+ :kiosks
+ (fn [db _]
+   (:kiosks db)))
+
+(rf/reg-sub
+ :kiosk
+ (fn [db _]
+   (:kiosk db)))
+
 (rf/reg-sub
  :current-match
  (fn [db _]
    (:current-match db)))
 
+(rf/reg-sub
+ :page-scroll
+ (fn [db _]
+   (:page-scroll db)))
+
 (rf/reg-sub
  :global-stream
  (fn [db _]
@@ -41,3 +66,13 @@
  :show-global-player
  (fn [db _]
    (:show-global-player db)))
+
+(rf/reg-sub
+ :show-page-loading
+ (fn [db _]
+   (:show-page-loading db)))
+
+(rf/reg-sub
+ :show-pagination-loading
+ (fn [db _]
+   (:show-pagination-loading db)))

+ 47 - 27
src/frontend/tau/views.cljs

@@ -1,53 +1,73 @@
 (ns tau.views
   (:require
-   [tau.views.player :as player]
    [reitit.frontend.easy :as rfe]
-   [re-frame.core :as rf]))
+   [re-frame.core :as rf]
+   [reagent.ratom :as ratom]
+   [tau.components.navigation :as navigation]
+   [tau.events :as events]
+   [tau.routes :as routes]
+   [tau.views.player :as player]))
+
+(defonce scroll-hook (.addEventListener js/window "scroll" #(rf/dispatch [::events/page-scroll])))
+(defonce services (rf/dispatch [::events/get-services]))
 
 (defn footer
   []
-  [:footer.bg-slate-900.text-gray-300.p-5.text-center
-   [:div
+  [:footer
+   [:div.bg-black.text-gray-300.p-5.text-center.w-full
     [:p (str "Tau " (.getFullYear (js/Date.)))]]])
 
 (defn search-bar
-  []
+  [{{:keys [serviceId]} :query-params}]
   (let [global-search @(rf/subscribe [:global-search])
         services @(rf/subscribe [:services])
-        service-id @(rf/subscribe [:service-id])]
+        service-id @(rf/subscribe [:service-id])
+        id (js/parseInt (or serviceId service-id)) ]
     [:div.flex
      [:form {:on-submit (fn [e]
                           (.preventDefault e)
-                          (rfe/push-state :tau.routes/search {} {:q global-search :serviceId service-id}))}
-      [:input.bg-slate-900.border.border-solid.border-black.rounded.py-2.px-1.mx-2.text-gray-500
+                          (rf/dispatch [::events/navigate
+                                        {:name ::routes/search
+                                         :params {}
+                                         :query  {:q global-search :serviceId service-id}}]))}
+      [:input.bg-neutral-900.border.border-solid.border-black.rounded.py-2.px-1.mx-2.text-gray-500
        {:type "text"
         :value global-search
-        :on-change #(rf/dispatch [:change-global-search (.. % -target -value)])
+        :on-change #(rf/dispatch [::events/change-global-search (.. % -target -value)])
         :placeholder "Search for something"}]
       [:select.mx-2.bg-gray-50.border.border-gray-900.text-gray-900
-       {:on-change #(rf/dispatch [:change-service-id (js/parseInt (.. % -target -value))])}
-       (for [service services]
-         [:option {:value (:id service) :key (:id service) :selected (= (:id service) service-id)}
-          (-> service :info :name)])]
-      [:button..bg-slate-900.border.border-black.rounded.border-solid.text-gray-500.p-2.mx-2
-       {:type "submit"} "Search"]]]))
+       {:on-change #(rf/dispatch [::events/change-service-id (js/parseInt (.. % -target -value))])}
+       (when services
+         (for [service services]
+           [:option {:value (:id service) :key (:id service) :selected (= id (:id service))}
+            (-> service :info :name)]))]
+      [:button.text-white.mx-2
+       {:type "submit"}
+       [:i.fas.fa-search]]]]))
 
-(defn navbar []
-  [:nav.bg-slate-800.flex.p-2.content-center.sticky.top-0.z-50
-   [:div.px-5.text-white.p-2
-    [:a {:href (rfe/href :tau.routes/home) :dangerouslySetInnerHTML {:__html "τ"}}]]
-   [:ul.flex.content-center.text-white.p-2
-    [:li.px-5 [:a {:href (rfe/href :tau.routes/home)} "Home"]]
-    [:li.px-5 [:a {:href (rfe/href :tau.routes/search)} "Search"]]]
-   [search-bar]])
+(defn navbar
+  [match]
+  (let [service-id @(rf/subscribe [:service-id])
+        service-color @(rf/subscribe [:service-color])
+        {:keys [default-kiosk available-kiosks]} @(rf/subscribe [:kiosks])]
+    (rf/dispatch [::events/get-kiosks service-id])
+    [:nav.flex.p-2.content-center.sticky.top-0.z-50
+     {:style {:background service-color}}
+     [:div.px-5.text-white.p-2.font-bold
+      [:a {:href (rfe/href ::routes/home) :dangerouslySetInnerHTML {:__html "τ"}}]]
+     [:ul.flex.content-center.p-2.text-white
+      (for [kiosk available-kiosks]
+        [:li.px-5 [:a {:href (rfe/href ::routes/kiosk nil {:serviceId service-id
+                                                           :kioskId kiosk})}
+                   kiosk]])]
+     [search-bar match]]))
 
 (defn app
   []
-  (rf/dispatch [:get-services])
   (let [current-match @(rf/subscribe [:current-match])]
-    [:div.font-sans.bg-slate-700.min-h-screen.flex.flex-col.h-full
-     [navbar]
-     [:div.flex.flex-col.justify-between {:class "min-h-[calc(100vh-58px)]"}
+    [:div.font-sans.min-h-screen.flex.flex-col.h-full {:style {:background "rgba(23, 23, 23)"}}
+     [navbar current-match]
+     [:div.flex.flex-col.justify-between.relative {:class "min-h-[calc(100vh-58px)]"}
       (when-let [view (-> current-match :data :view)]
         [view current-match])
       [player/global-player]

+ 41 - 0
src/frontend/tau/views/channel.cljs

@@ -0,0 +1,41 @@
+(ns tau.views.channel
+  (:require
+   [re-frame.core :as rf]
+   [tau.components.items :as items]
+   [tau.components.loading :as loading]
+   [tau.components.navigation :as navigation]
+   [tau.events :as events]))
+
+(defn channel
+  [{{:keys [url]} :query-params}]
+  (let [{:keys [banner avatar name description subscriber-count
+                related-streams next-page]} @(rf/subscribe [:channel])
+        next-page-url (:url next-page)
+        service-color @(rf/subscribe [:service-color])
+        page-scroll @(rf/subscribe [:page-scroll])
+        page-loading? @(rf/subscribe [:show-page-loading])
+        pagination-loading? @(rf/subscribe [:show-pagination-loading])
+        scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))]
+    (when scrolled-to-bottom?
+      (rf/dispatch [::events/scroll-channel-pagination url next-page-url]))
+    [:div.flex.flex-col.items-center.px-5.py-2.text-white.flex-auto
+     (if page-loading?
+       [loading/page-loading-icon service-color]
+       [:div {:class "w-4/5"}
+        [navigation/back-button]
+        [:div [:img {:src banner}]]
+        [:div.flex.items-center.my-4.mx-2
+         [:div
+          [:img.rounded-full {:src avatar}]]
+         [:div.m-4
+          [:h1.text-xl name]
+          [:div.flex.my-2.items-center
+           [:i.fa-solid.fa-users]
+           [:span.mx-2 subscriber-count]]]]
+        [:div.my-2
+         [:p description]]
+        [:div.flex.justify-center.align-center.flex-wrap.my-2
+         (for [[i result] (map-indexed vector related-streams)]
+           [items/stream-item i result])]
+        (when-not (empty? next-page-url)
+           [loading/pagination-loading-icon service-color pagination-loading?])])]))

+ 1 - 1
src/frontend/tau/views/home.cljs

@@ -1,7 +1,7 @@
 (ns tau.views.home)
 
 (defn home-page
-  []
+  [match]
   [:div.flex.justify-center.content-center.flex-col.text-center.text-white.text-lg.flex-auto
    [:p.text-5xl.p-5 "Welcome to Tau"]
    [:p.text-2xl "A web front-end for Newpipe"]])

+ 9 - 0
src/frontend/tau/views/kiosk.cljs

@@ -0,0 +1,9 @@
+(ns tau.views.kiosk
+  (:require
+   [re-frame.core :as rf]))
+
+(defn kiosk
+  [match]
+  (let [{:keys [id url related-streams]} @(rf/subscribe [:kiosk])]
+    [:div
+     [:h1 id]]))

+ 5 - 0
src/frontend/tau/views/playlist.cljs

@@ -0,0 +1,5 @@
+(ns tau.views.playlist)
+
+(defn playlist
+  [match]
+  [:div])

+ 31 - 33
src/frontend/tau/views/search.cljs

@@ -1,38 +1,36 @@
 (ns tau.views.search
   (:require
    [re-frame.core :as rf]
-   [reitit.frontend.easy :as rfe]))
-
-(defn search-result
-  [title author url thumbnail id]
-  [:div.w-56.h-64.my-2 {:key id}
-   [:div.p-5.border.rounded.border-slate-900.m-2.bg-slate-600.flex.flex-col.max-w-full.min-h-full.max-h-full
-    [:a.overflow-hidden {:href (rfe/href :tau.routes/stream {} {:url url}) :title title}
-     [:div.flex.justify-center.min-w-full.py-3.box-border
-      [:div.h-28.min-w-full.flex.justify-center
-       [:img.rounded.object-cover.max-h-full {:src thumbnail}]]]
-     [:div.overflow-hidden
-      [:h1.text-gray-300.font-bold author]
-      [:h1 title]]]]])
+   [reitit.frontend.easy :as rfe]
+   [tau.components.items :as items]
+   [tau.components.loading :as loading]
+   [tau.events :as events]))
 
 (defn search
-  [m]
-  (let [search-results (rf/subscribe [:search-results])
-        services (rf/subscribe [:services])
-        service-id (rf/subscribe [:service-id])]
-    [:div.text-gray-300.text-center.py-5.relative
-     [:h2 (str "Showing search results for: \"" (-> m :query-params :q) "\"")]
-     [:h1 (str "Number of search results: " (count (:items @search-results)))]
-     ;; TODO: Create loadable component that wraps other components that need to fetch from API
-     ;; or use a :loading key to show a spinner component instead
-     (if (empty? @search-results)
-       [:p "Loading"]
-       [:div.flex.justify-center.align-center.flex-wrap
-        (for [[i result] (map-indexed vector (:items @search-results))]
-          ;; TODO: Add a component per result type
-          [search-result
-           (:name result)
-           (:upload-author result)
-           (:url result)
-           (:thumbnail-url result)
-           i])])]))
+  [{{:keys [q serviceId]} :query-params}]
+  (let [{:keys [items next-page] :as search-results} @(rf/subscribe [:search-results])
+        next-page-url (:url next-page)
+        services @(rf/subscribe [:services])
+        service-id @(rf/subscribe [:service-id])
+        service-color @(rf/subscribe [:service-color])
+        page-scroll @(rf/subscribe [:page-scroll])
+        page-loading? @(rf/subscribe [:show-page-loading])
+        pagination-loading? @(rf/subscribe [:show-pagination-loading])
+        scrolled-to-bottom? (= page-scroll (.-scrollHeight js/document.body))]
+    (when scrolled-to-bottom?
+      (rf/dispatch [::events/scroll-search-pagination q serviceId next-page-url]))
+    [:div.flex.flex-col.text-gray-300.h-box-border.flex-auto
+     [:div.flex.flex-col.items-center.w-full.pt-4.flex-initial
+      [:h2 (str "Showing search results for: \"" q "\"")]
+      [:h1 (str "Number of search results: " (count items))]]
+     (if page-loading?
+       [loading/page-loading-icon service-color]
+       [:div.flex.flex-col
+        [:div.flex.justify-center.align-center.flex-wrap.flex-auto
+         (for [[i item] (map-indexed vector items)]
+           (cond
+             (:duration item) [items/stream-item i item]
+             (:subscriber-count item) [items/channel-item i item]
+             (:stream-count item) [items/playlist-item i item]))
+         (when-not (empty? next-page-url)
+           [loading/pagination-loading-icon service-color pagination-loading?])]])]))

+ 76 - 22
src/frontend/tau/views/stream.cljs

@@ -1,27 +1,81 @@
 (ns tau.views.stream
   (:require
-   [re-frame.core :as rf]))
+   [re-frame.core :as rf]
+   [reitit.frontend.easy :as rfe]
+   [tau.events :as events]
+   [tau.components.items :as items]
+   [tau.components.loading :as loading]
+   [tau.components.navigation :as navigation]))
 
 (defn stream
-  [m]
-  (let [current-stream @(rf/subscribe [:stream])
-        stream-type (-> (if (empty? (:video-streams current-stream))
-                          (:audio-streams current-stream)
-                          (:video-streams current-stream))
+  [match]
+  (let [{:keys [name url video-streams audio-streams view-count
+                subscriber-count like-count dislike-count
+                description upload-avatar upload-author
+                upload-url upload-date related-streams
+                thumbnail-url] :as stream} @(rf/subscribe [:stream])
+        stream-type (-> (if (empty? video-streams) audio-streams video-streams)
                         last
-                        :content)]
-       [:div.flex.flex-col.justify-center.p-5.items-center
-        [:div.flex.justify-center.py-2
-         [:div.flex.justify-center {:class "w-4/5"}
-          [:video.min-w-full.h-auto {:src stream-type :controls true}]]]
-        [:div.flex.text-white
-         [:button.border.rounded.border-slate-900.p-2.bg-slate-800
-          {:on-click #(rf/dispatch [:switch-to-global-player current-stream])}
-          "Add to global stream"]
-         [:a {:href (:url current-stream)}
-          "Open original source"]]
-        [:div.flex.flex-col.items-center.py-2 {:class "w-4/5"}
-         [:div.min-w-full.py-2
-          [:h1.text-xl.font-extrabold (:name current-stream)]]
-         [:div.min-w-full.py-2
-          [:p (:description current-stream)]]]]))
+                        :content)
+        page-loading? @(rf/subscribe [:show-page-loading])
+        service-color @(rf/subscribe [:service-color])]
+    [:div.flex.flex-col.p-5.items-center.justify-center.text-white.flex-auto
+     (if page-loading?
+       [loading/page-loading-icon service-color]
+       [:div {:class "w-4/5"}
+        [navigation/back-button]
+        [:div.flex.justify-center.relative.my-2
+         {:style {:background (str "center / cover no-repeat url('" thumbnail-url"')")
+                  :height "450px"}}
+         [:video.min-h-full.absolute.bottom-0.object-cover {:src stream-type :controls true :width "100%"}]]
+        [:div.flex.text-white.flex.w-full.my-1
+         [:button.border.rounded.border-black.p-2.bg-stone-800
+          {:on-click #(rf/dispatch [::events/switch-to-global-player stream])}
+          [:i.fa-solid.fa-headphones]]
+         [:a {:href (:url stream)}
+          [:button.border.rounded.border-black.p-2.bg-stone-800.mx-2
+           [:i.fa-solid.fa-external-link-alt]]]]
+        [:div.flex.flex-col.py-1
+         [:div.min-w-full.py-3
+          [:h1.text-xl.font-extrabold name]]
+         [:div.flex.justify-between.py-2
+          [:div.flex.items-center.flex-auto
+           (when upload-avatar
+             [:div
+              [:img.rounded-full {:src upload-avatar :alt upload-author}]])
+           [:div.mx-2
+            [:a {:href (rfe/href :tau.routes/channel nil {:url upload-url})} upload-author]
+            (when subscriber-count
+              [:div.flex.my-2
+               [:i.fa-solid.fa-users]
+               [:p.mx-2 (.toLocaleString subscriber-count)]])]]
+          [:div
+           (when view-count
+             [:p
+              [:i.fa-solid.fa-eye]
+              [:span.mx-2 (.toLocaleString view-count)]])
+           [:div
+            (when like-count
+              [:p
+               [:i.fa-solid.fa-thumbs-up]
+               [:span.mx-2 like-count]])
+            (when dislike-count
+              [:p
+               [:i.fa-solid.fa-thumbs-down]
+               [:span.mx-2 dislike-count]])]
+           (when upload-date
+             [:p (-> upload-date
+                     js/Date.parse
+                     js/Date.
+                     .toDateString)])]]
+         [:div.min-w-full.py-3
+          [:h1 name]
+          [:p description]]
+         [:div.py-3
+          [:h1.text-lg.bold "Related Results"]
+          [:div.flex.justify-center.align-center.flex-wrap
+           (for [[i item] (map-indexed vector related-streams)]
+             (cond
+               (:duration item) [items/stream-item i item]
+               (:subscriber-count item) [items/channel-item i item]
+               (:stream-count item) [items/playlist-item i item]))]]]])]))