├── .idea ├── .gitignore ├── misc.xml ├── vcs.xml ├── ClojureProjectResolveSettings.xml ├── watcherTasks.xml ├── modules.xml ├── conduit.iml └── inspectionProfiles │ └── Project_Default.xml ├── test ├── runner.cljs └── core_test.cljs ├── .gitignore ├── shadow-cljs.edn ├── package.json ├── LICENSE.md ├── src └── conduit │ ├── core.cljs │ ├── subs.cljs │ ├── db.cljs │ ├── router.cljs │ ├── views.cljs │ └── events.cljs ├── public └── index.html └── README.md /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml -------------------------------------------------------------------------------- /test/runner.cljs: -------------------------------------------------------------------------------- 1 | (ns test.runner 2 | (:require [doo.runner :refer-macros [doo-tests]] 3 | [test.core-test])) 4 | 5 | (doo-tests 'test.core-test) 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/ClojureProjectResolveSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | IDE 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /target 3 | /*-init.clj 4 | /resources/public/js 5 | /resources/public/test 6 | .nrepl-port 7 | .lein-failures 8 | out 9 | .DS_Store 10 | /node_modules 11 | /target 12 | /public/js 13 | /.shadow-cljs 14 | yarn.lock 15 | /.idea -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /test/core_test.cljs: -------------------------------------------------------------------------------- 1 | (ns test.core-test 2 | (:require [cljs.test :refer-macros [deftest testing is]] 3 | [test.core :as core])) 4 | 5 | ;; Working on it ... 6 | ;; 7 | (deftest one-is-one 8 | (testing "if one equals one" 9 | (is (= 1 1)))) 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/conduit.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:source-paths ["src"] 2 | 3 | :dependencies [[reagent "0.9.1"] 4 | [re-frame "0.10.7"] 5 | [day8.re-frame/http-fx "v0.2.0"] 6 | [cljs-ajax "0.7.3"] 7 | [bidi "2.1.5"] 8 | [kibu/pushy "0.3.8"] 9 | [binaryage/devtools "0.9.10"]] 10 | 11 | :fs-watch {:hawk false} 12 | 13 | :nrepl {:port 3333} 14 | 15 | :builds {:app {:target :browser 16 | :output-dir "public/js" 17 | :asset-path "/js" 18 | 19 | :modules {:main {:init-fn conduit.core/init}} 20 | 21 | :compiler-options {:shadow-keywords true} 22 | 23 | :devtools {:http-root "public" 24 | :http-port 3000}}}} 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "conduit", 3 | "version": "0.0.1", 4 | "description": "ClojureScript and re-frame codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the RealWorld spec and API.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/jacekschae/conduit" 8 | }, 9 | "author": { 10 | "name": "Jacek Schae", 11 | "email": "jacek.schae@gmail.com" 12 | }, 13 | "license": "MIT", 14 | "scripts": { 15 | "dev": "shadow-cljs watch app", 16 | "release": "shadow-cljs release app", 17 | "server": "shadow-cljs server", 18 | "clean": "rm -rf target; rm -rf public/js", 19 | "clean-win": "rmdir /s /q public/js & rmdir /s /q target" 20 | }, 21 | "dependencies": { 22 | "react": "16.9.0", 23 | "react-dom": "16.9.0" 24 | }, 25 | "devDependencies": { 26 | "shadow-cljs": "^2.8.90" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2018] [Jacek Schae] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/conduit/core.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.core 2 | (:require [re-frame.core :refer [dispatch-sync]] 3 | [reagent.core :as reagent] 4 | [conduit.router :as router] 5 | [conduit.events] ;; These three are only 6 | [conduit.subs] ;; required to make the compiler 7 | [conduit.views])) ;; load them 8 | 9 | ;; -- Entry Point ------------------------------------------------------------- 10 | ;; Within ../../resources/public/index.html you'll see this code: 11 | ;; window.onload = function() { conduit.core.main() } this is the entry 12 | ;; function that kicks off the app once the HTML is loaded. 13 | ;; 14 | (defn ^:export init 15 | [] 16 | ;; Router config can be found within `./router.cljs`. Here we are just hooking 17 | ;; up the router on start 18 | (router/start!) 19 | 20 | ;; Put an initial value into app-db. 21 | ;; The event handler for `:initialise-db` can be found in `events.cljs` 22 | ;; Using the sync version of dispatch means that value is in 23 | ;; place before we go onto the next step. 24 | (dispatch-sync [:initialise-db]) 25 | 26 | ;; Render the UI into the HTML's
element 27 | ;; The view function `conduit.views/conduit-app` is the 28 | ;; root view for the entire UI. 29 | (reagent/render [conduit.views/conduit-app] 30 | (.getElementById js/document "app"))) 31 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | Conduit 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](https://cloud.githubusercontent.com/assets/556934/25448267/85369fdc-2a7d-11e7-9613-ab5ce5e1800f.png) 2 | 3 | > ### [ClojureScript](https://clojurescript.org/) and [re-frame](https://github.com/Day8/re-frame) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API. 4 | 5 | ### [Demo](https://conduit-re-frame-demo.netlify.com/)      [Demo with re-frame-10x](https://jacekschae.github.io/conduit-re-frame-10x-demo/)      [Demo with re-frisk](https://flexsurfer.github.io/conduit-re-frisk-demo/)      [RealWorld](https://github.com/gothinkster/realworld) 6 | 7 | This codebase was created to demonstrate a fully fledged fullstack application built with 8 | [ClojureScript](https://clojurescript.org/) and [re-frame](https://github.com/Day8/re-frame) including CRUD operations, 9 | authentication, routing, pagination, and more. 10 | 11 | For more information on how this works with other frontends/backends, head over to the 12 | [RealWorld](https://github.com/gothinkster/realworld) repo. 13 | 14 | > #### Application structure/style heavily inspired by [todomvc](https://github.com/Day8/re-frame/tree/master/examples/todomvc) 15 | 16 | # Learn how to build similar project with [Learn re-frame](https://www.learnreframe.com/) 17 | 18 | 19 | ## Setup And Run 20 | 21 | #### Copy repository 22 | ```shell 23 | git clone https://github.com/jacekschae/conduit.git && cd conduit 24 | ``` 25 | 26 | #### Install dependencies 27 | ```shell 28 | yarn install || npm install 29 | ``` 30 | 31 | #### Run dev server 32 | ```shell 33 | yarn dev || npm run dev 34 | ``` 35 | 36 | #### Compile an optimized version 37 | 38 | ```shell 39 | yarn release || npm run release 40 | ``` 41 | -------------------------------------------------------------------------------- /src/conduit/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.subs 2 | (:require [re-frame.core :refer [reg-sub]])) 3 | 4 | (defn reverse-cmp ;; https://clojure.org/guides/comparators 5 | "Sort numbers in decreasing order, i.e.: calls compare with the arguments in the opposite order" 6 | [a b] 7 | (compare b a)) 8 | 9 | (reg-sub 10 | :active-page ;; usage: (subscribe [:active-page]) 11 | (fn [db _] ;; db is the (map) value stored in the app-db atom 12 | (:active-page db))) ;; extract a value from the application state 13 | 14 | (reg-sub 15 | :articles ;; usage: (subscribe [:articles]) 16 | (fn [db _] ;; db is the (map) value stored in the app-db atom 17 | (->> (:articles db) ;; ->> is a thread last macro - pass atricles as last arg of: 18 | (vals) ;; vals, just as we would write (vals articles), then pass the result to: 19 | (sort-by :epoch reverse-cmp)))) ;; sort-by epoch in reverse order 20 | 21 | (reg-sub 22 | :articles-count ;; usage: (subscribe [:articles-count]) 23 | (fn [db _] 24 | (:articles-count db))) 25 | 26 | (reg-sub 27 | :active-article ;; usage (subscribe [:active-article]) 28 | (fn [db _] 29 | (get-in db [:articles (:active-article db)]))) 30 | 31 | (reg-sub 32 | :tags ;; usage: (subscribe [:tags]) 33 | (fn [db _] 34 | (:tags db))) 35 | 36 | (reg-sub 37 | :comments ;; usage: (subscribe [:comments]) 38 | (fn [db _] 39 | (->> (:comments db) 40 | (vals) 41 | (sort-by :epoch reverse-cmp)))) 42 | 43 | (reg-sub 44 | :profile ;; usage: (subscribe [:profile]) 45 | (fn [db _] 46 | (:profile db))) 47 | 48 | (reg-sub 49 | :loading ;; usage: (subscribe [:loading]) 50 | (fn [db _] 51 | (:loading db))) 52 | 53 | (reg-sub 54 | :filter ;; usage: (subscribe [:filter]) 55 | (fn [db _] 56 | (:filter db))) 57 | 58 | (reg-sub 59 | :errors ;; usage: (subscribe [:errors]) 60 | (fn [db _] 61 | (:errors db))) 62 | 63 | (reg-sub 64 | :user ;; usage: (subscribe [:user]) 65 | (fn [db _] 66 | (:user db))) 67 | -------------------------------------------------------------------------------- /src/conduit/db.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.db 2 | (:require [cljs.reader] 3 | [re-frame.core :refer [reg-cofx]])) 4 | 5 | ;; -- Default app-db Value --------------------------------------------------- 6 | ;; 7 | ;; When the application first starts, this will be the value put in app-db 8 | ;; Look in: 9 | ;; 1. `core.cljs` for "(dispatch-sync [:initialise-db])" 10 | ;; 2. `events.cljs` for the registration of :initialise-db handler 11 | ;; 12 | (def default-db {:active-page :home}) ;; what gets put into app-db by default. 13 | 14 | ;; -- Local Storage ---------------------------------------------------------- 15 | ;; 16 | ;; Part of the conduit challenge is to store a user in localStorage, and 17 | ;; on app startup, reload the user from when the program was last run. 18 | ;; 19 | (def conduit-user-key "conduit-user") ;; localstore key 20 | 21 | (defn set-user-ls 22 | [user] 23 | (.setItem js/localStorage conduit-user-key (str user))) ;; sorted-map written as an EDN map 24 | 25 | ;; Removes user information from localStorge when a user logs out. 26 | ;; 27 | (defn remove-user-ls 28 | [] 29 | (.removeItem js/localStorage conduit-user-key)) 30 | 31 | ;; -- cofx Registrations ----------------------------------------------------- 32 | ;; 33 | ;; Use `reg-cofx` to register a "coeffect handler" which will inject the user 34 | ;; stored in localStorge. 35 | ;; 36 | ;; To see it used, look in `events.cljs` at the event handler for `:initialise-db`. 37 | ;; That event handler has the interceptor `(inject-cofx :local-store-user)` 38 | ;; The function registered below will be used to fulfill that request. 39 | ;; 40 | ;; We must supply a `sorted-map` but in localStorage it is stored as a `map`. 41 | ;; 42 | (reg-cofx 43 | :local-store-user 44 | (fn [cofx _] 45 | (assoc cofx :local-store-user ;; put the local-store user into the coeffect under :local-store-user 46 | (into (sorted-map) ;; read in user from localstore, and process into a sorted map 47 | (some->> (.getItem js/localStorage conduit-user-key) 48 | (cljs.reader/read-string)))))) ;; EDN map -> map 49 | -------------------------------------------------------------------------------- /src/conduit/router.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.router 2 | (:require [bidi.bidi :as bidi] 3 | [pushy.core :as pushy] 4 | [re-frame.core :refer [dispatch]])) 5 | 6 | ;; The routes setup is inspired by J. Pablo Fernández 7 | ;; source: https://pupeno.com/2015/08/26/no-hashes-bidirectional-routing-in-re-frame-with-bidi-and-pushy/ 8 | 9 | ;; -- Routes ------------------------------------------------------------------ 10 | ;; Define routes so that when we enter specific path the router knows what to 11 | ;; show us. A route is simply a data structure - Vector - with a pattern and 12 | ;; a result. 13 | (def routes 14 | ["/" {"" :home 15 | "login" :login 16 | "logout" :logout 17 | "register" :register 18 | "settings" :settings 19 | "editor/" {[:slug] :editor} 20 | "article/" {[:slug] :article} 21 | "profile/" {[:user-id] {"" :profile 22 | "/favorites" :favorited}}}]) 23 | 24 | ;; -- history ----------------------------------------------------------------- 25 | ;; we need to know the history of our routes so that we can navigate back and 26 | ;; forward. For that we'll use pushy/pushy to which we need to provide dispatch 27 | ;; function: what happens on dispatch and match: what routes should we match 28 | (def history 29 | (let [dispatch #(dispatch [:set-active-page {:page (:handler %) 30 | :slug (get-in % [:route-params :slug]) 31 | :profile (get-in % [:route-params :user-id]) 32 | :favorited (get-in % [:route-params :user-id])}]) 33 | match #(bidi/match-route routes %)] 34 | (pushy/pushy dispatch match))) 35 | 36 | ;; -- Router Start ------------------------------------------------------------ 37 | ;; 38 | (defn start! 39 | [] 40 | ;; pushy is here to take care of nice looking urls. Normally we would have to 41 | ;; deal with #. By using pushy we can have '/about' instead of '/#/about'. 42 | ;; pushy takes three arguments: 43 | ;; dispatch-fn - which dispatches when a match is found 44 | ;; match-fn - which checks if a route exist 45 | ;; identity-fn (optional) - extract the route from value returned by match-fn 46 | (pushy/start! history)) 47 | 48 | ;; -- url-for ----------------------------------------------------------------- 49 | ;; To dispatch routes in our UI (view) we will use url-for and then pass a 50 | ;; keyword to which route we want to direct the user. 51 | ;; usage: (url-for :home) 52 | (def url-for (partial bidi/path-for routes)) 53 | 54 | ;; -- set-token! -------------------------------------------------------------- 55 | ;; To change route after some actions we will need to set url and for that we 56 | ;; will use set-token! that needs history and the token 57 | (defn set-token! 58 | [token] 59 | (pushy/set-token! history token)) 60 | -------------------------------------------------------------------------------- /src/conduit/views.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.views 2 | (:require [reagent.core :as reagent] 3 | [conduit.router :refer [url-for]] 4 | [re-frame.core :refer [subscribe dispatch]] 5 | [clojure.string :as str :refer [trim split join]])) 6 | 7 | ;; -- Helpers ----------------------------------------------------------------- 8 | ;; 9 | (defn format-date 10 | [date] 11 | (.toDateString (js/Date. date))) 12 | 13 | (defn tags-list 14 | [tags-list] 15 | [:ul.tag-list 16 | (for [tag tags-list] 17 | [:li.tag-default.tag-pill.tag-outline {:key tag} tag])]) 18 | 19 | (defn article-meta 20 | [{:keys [author createdAt favoritesCount favorited slug] :or {slug "" author {:username ""}}}] 21 | (let [loading @(subscribe [:loading]) 22 | user @(subscribe [:user]) 23 | profile @(subscribe [:profile]) 24 | username (:username author)] 25 | [:div.article-meta 26 | [:a {:href (url-for :profile :user-id username)} 27 | [:img {:src (:image author) :alt "user image"}] " "] 28 | [:div.info 29 | [:a.author {:href (url-for :profile :user-id username)} username] 30 | [:span.date (format-date createdAt)]] 31 | (if (= (:username user) username) 32 | [:span 33 | [:a.btn.btn-sm.btn-outline-secondary {:href (url-for :editor :slug slug)} 34 | [:i.ion-edit] 35 | [:span " Edit Article "]] 36 | " " 37 | [:a.btn.btn-outline-danger.btn-sm {:href (url-for :home) 38 | :on-click #(dispatch [:delete-article slug])} 39 | [:i.ion-trash-a] 40 | [:span " Delete Article "]]] 41 | (when-not (empty? user) 42 | [:span 43 | [:button.btn.btn-sm.action-btn.btn-outline-secondary {:on-click #(dispatch [:toggle-follow-user username]) 44 | :class (when (:toggle-follow-user loading) "disabled")} 45 | [:i {:class (if (:following profile) "ion-minus-round" "ion-plus-round")}] 46 | [:span (if (:following profile) (str " Unfollow " username) (str " Follow " username))]] 47 | " " 48 | [:button.btn.btn-sm.btn-primary {:on-click #(dispatch [:toggle-favorite-article slug]) 49 | :class (cond 50 | (not favorited) "btn-outline-primary" 51 | (:toggle-favorite-article loading) "disabled")} 52 | [:i.ion-heart] 53 | [:span (if favorited " Unfavorite Post " " Favorite Post ")] 54 | [:span.counter "(" favoritesCount ")"]]]))])) 55 | 56 | (defn articles-preview 57 | [{:keys [description slug createdAt title author favoritesCount favorited tagList] :or {slug "" author {:username ""}}}] 58 | (let [loading @(subscribe [:loading]) 59 | user @(subscribe [:user]) 60 | username (:username author)] 61 | [:div.article-preview 62 | [:div.article-meta 63 | [:a {:href (url-for :profile :user-id username)} 64 | [:img {:src (:image author) :alt "user image"}]] 65 | [:div.info 66 | [:a.author {:href (url-for :profile :user-id username)} username] 67 | [:span.date (format-date createdAt)]] 68 | (when-not (empty? user) 69 | [:button.btn.btn-primary.btn-sm.pull-xs-right {:on-click #(dispatch [:toggle-favorite-article slug]) 70 | :class (cond 71 | (not favorited) "btn-outline-primary" 72 | (:toggle-favorite-article loading) "disabled")} 73 | [:i.ion-heart " "] 74 | [:span favoritesCount]])] 75 | [:a.preview-link {:href (url-for :article :slug slug)} 76 | [:h1 title] 77 | [:p description] 78 | [:span "Read more ..."] 79 | [tags-list tagList]]])) ;; defined in Helpers section 80 | 81 | (defn articles-list 82 | [articles loading-articles] 83 | [:div 84 | (if loading-articles 85 | [:div.article-preview 86 | [:p "Loading articles ..."]] 87 | (if (empty? articles) 88 | [:div.article-preview 89 | [:p "No articles are here... yet."]] 90 | (for [article articles] 91 | ^{:key (:slug article)} [articles-preview article])))]) 92 | 93 | (defn errors-list 94 | [errors] 95 | [:ul.error-messages 96 | (for [[key [val]] errors] 97 | ^{:key key} [:li (str (name key) " " val)])]) 98 | 99 | ;; -- Header ------------------------------------------------------------------ 100 | ;; 101 | (defn header 102 | [] 103 | (let [user @(subscribe [:user]) 104 | active-page @(subscribe [:active-page])] 105 | [:nav.navbar.navbar-light 106 | [:div.container 107 | [:a.navbar-brand {:href (url-for :home)} "conduit"] 108 | (if (empty? user) 109 | [:ul.nav.navbar-nav.pull-xs-right 110 | [:li.nav-item 111 | [:a.nav-link {:href (url-for :home) :class (when (= active-page :home) "active")} "Home"]] 112 | [:li.nav-item 113 | [:a.nav-link {:href (url-for :login) :class (when (= active-page :login) "active")} "Sign in"]] 114 | [:li.nav-item 115 | [:a.nav-link {:href (url-for :register) :class (when (= active-page :register) "active")} "Sign up"]]] 116 | [:ul.nav.navbar-nav.pull-xs-right 117 | [:li.nav-item 118 | [:a.nav-link {:href (url-for :home) :class (when (= active-page :home) "active")} "Home"]] 119 | [:li.nav-item 120 | [:a.nav-link {:href (url-for :editor :slug "new") :class (when (= active-page :editor) "active")} 121 | [:i.ion-compose "New Article"]]] 122 | [:li.nav-item 123 | [:a.nav-link {:href (url-for :settings) :class (when (= active-page :settings) "active")} 124 | [:i.ion-gear-a "Settings"]]] 125 | [:li.nav-item 126 | [:a.nav-link {:href (url-for :profile :user-id (:username user)) :class (when (= active-page :profile) "active")} (:username user) 127 | [:img.user-pic {:src (:image user) :alt "user image"}]]]])]])) 128 | 129 | ;; -- Footer ------------------------------------------------------------------ 130 | ;; 131 | (defn footer 132 | [] 133 | [:footer 134 | [:div.container 135 | [:a.logo-font {:href (url-for :home)} "conduit"] 136 | [:span.attribution 137 | "An interactive learning project from " 138 | [:a {:href "https://thinkster.io"} "Thinkster"] 139 | ". Code & design licensed under MIT."]]]) 140 | 141 | ;; -- Home -------------------------------------------------------------------- 142 | ;; 143 | (defn home 144 | [] 145 | (let [filter @(subscribe [:filter]) 146 | tags @(subscribe [:tags]) 147 | loading @(subscribe [:loading]) 148 | articles @(subscribe [:articles]) 149 | articles-count @(subscribe [:articles-count]) 150 | user @(subscribe [:user]) 151 | get-articles (fn [event params] 152 | (.preventDefault event) 153 | (dispatch [:get-articles params])) 154 | get-feed-articles (fn [event params] 155 | (.preventDefault event) 156 | (dispatch [:get-feed-articles params]))] 157 | [:div.home-page 158 | (when (empty? user) 159 | [:div.banner 160 | [:div.container 161 | [:h1.logo-font "conduit"] 162 | [:p "A place to share your knowledge."]]]) 163 | [:div.container.page 164 | [:div.row 165 | [:div.col-md-9 166 | [:div.feed-toggle 167 | [:ul.nav.nav-pills.outline-active 168 | (when-not (empty? user) 169 | [:li.nav-item 170 | [:a.nav-link {:href (url-for :home) 171 | :class (when (:feed filter) "active") 172 | :on-click #(get-feed-articles % {:offset 0 173 | :limit 10})} "Your Feed"]]) 174 | [:li.nav-item 175 | [:a.nav-link {:href (url-for :home) 176 | :class (when-not (or (:tag filter) (:feed filter)) "active") 177 | :on-click #(get-articles % {:offset 0 178 | :limit 10})} "Global Feed"]] ;; first argument: % is browser event, second: map of filter params 179 | (when (:tag filter) 180 | [:li.nav-item 181 | [:a.nav-link.active 182 | [:i.ion-pound] (str " " (:tag filter))]])]] 183 | [articles-list articles (:articles loading)] 184 | (when-not (or (:articles loading) (< articles-count 10)) 185 | [:ul.pagination 186 | (for [offset (range (/ articles-count 10))] 187 | ^{:key offset} [:li.page-item {:class (when (= (* offset 10) (:offset filter)) "active") 188 | :on-click #(get-articles % (if (:tag filter) 189 | {:offset (* offset 10) 190 | :tag (:tag filter) 191 | :limit 10} 192 | {:offset (* offset 10) 193 | :limit 10}))} 194 | [:a.page-link {:href (url-for :home)} (+ 1 offset)]])])] 195 | [:div.col-md-3 196 | [:div.sidebar 197 | [:p "Popular Tags"] 198 | (if (:tags loading) 199 | [:p "Loading tags ..."] 200 | [:div.tag-list 201 | (for [tag tags] 202 | ^{:key tag} [:a.tag-pill.tag-default {:href (url-for :home) 203 | :on-click #(get-articles % {:tag tag 204 | :limit 10 205 | :offset 0})} tag])])]]]]])) 206 | 207 | ;; -- Login ------------------------------------------------------------------- 208 | ;; 209 | (defn login 210 | [] 211 | (let [default {:email "" :password ""} 212 | credentials (reagent/atom default)] 213 | (fn [] 214 | (let [{:keys [email password]} @credentials 215 | loading @(subscribe [:loading]) 216 | errors @(subscribe [:errors]) 217 | login-user (fn [event credentials] 218 | (.preventDefault event) 219 | (dispatch [:login credentials]))] 220 | [:div.auth-page 221 | [:div.container.page 222 | [:div.row 223 | [:div.col-md-6.offset-md-3.col-xs-12 224 | [:h1.text-xs-center "Sign in"] 225 | [:p.text-xs-center 226 | [:a {:href (url-for :register)} "Need an account?"]] 227 | (when (:login errors) 228 | [errors-list (:login errors)]) 229 | [:form {:on-submit #(login-user % @credentials)} 230 | [:fieldset.form-group 231 | [:input.form-control.form-control-lg {:type "text" 232 | :placeholder "Email" 233 | :value email 234 | :on-change #(swap! credentials assoc :email (-> % .-target .-value)) 235 | :disabled (when (:login loading))}]] 236 | 237 | [:fieldset.form-group 238 | [:input.form-control.form-control-lg {:type "password" 239 | :placeholder "Password" 240 | :value password 241 | :on-change #(swap! credentials assoc :password (-> % .-target .-value)) 242 | :disabled (when (:login loading))}]] 243 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:class (when (:login loading) "disabled")} "Sign in"]]]]]])))) 244 | 245 | ;; -- Register ---------------------------------------------------------------- 246 | ;; 247 | (defn register 248 | [] 249 | (let [default {:username "" :email "" :password ""} 250 | registration (reagent/atom default)] 251 | (fn [] 252 | (let [{:keys [username email password]} @registration 253 | loading @(subscribe [:loading]) 254 | errors @(subscribe [:errors]) 255 | register-user (fn [event registration] 256 | (.preventDefault event) 257 | (dispatch [:register-user registration]))] 258 | [:div.auth-page 259 | [:div.container.page 260 | [:div.row 261 | [:div.col-md-6.offset-md-3.col-xs-12 262 | [:h1.text-xs-center "Sign up"] 263 | [:p.text-xs-center 264 | [:a {:href (url-for :login)} "Have an account?"]] 265 | (when (:register-user errors) 266 | [errors-list (:register-user errors)]) 267 | [:form {:on-submit #(register-user % @registration)} 268 | [:fieldset.form-group 269 | [:input.form-control.form-control-lg {:type "text" 270 | :placeholder "Your Name" 271 | :value username 272 | :on-change #(swap! registration assoc :username (-> % .-target .-value)) 273 | :disabled (when (:register-user loading))}]] 274 | [:fieldset.form-group 275 | [:input.form-control.form-control-lg {:type "text" 276 | :placeholder "Email" 277 | :value email 278 | :on-change #(swap! registration assoc :email (-> % .-target .-value)) 279 | :disabled (when (:register-user loading))}]] 280 | [:fieldset.form-group 281 | [:input.form-control.form-control-lg {:type "password" 282 | :placeholder "Password" 283 | :value password 284 | :on-change #(swap! registration assoc :password (-> % .-target .-value)) 285 | :disabled (when (:register-user loading))}]] 286 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:class (when (:register-user loading) "disabled")} "Sign up"]]]]]])))) 287 | 288 | ;; -- Profile ----------------------------------------------------------------- 289 | ;; 290 | (defn profile 291 | [] 292 | (let [{:keys [image username bio following] :or {username ""}} @(subscribe [:profile]) 293 | {:keys [author favorites]} @(subscribe [:filter]) 294 | loading @(subscribe [:loading]) 295 | articles @(subscribe [:articles]) 296 | user @(subscribe [:user])] 297 | [:div.profile-page 298 | [:div.user-info 299 | [:div.container 300 | [:div.row 301 | [:div.col-xs-12.col-md-10.offset-md-1 302 | [:img.user-img {:src image :alt "user image"}] 303 | [:h4 username] 304 | [:p bio] 305 | (if (= (:username user) username) 306 | [:a.btn.btn-sm.btn-outline-secondary.action-btn {:href (url-for :settings)} 307 | [:i.ion-gear-a] " Edit Profile Settings"] 308 | [:button.btn.btn-sm.action-btn.btn-outline-secondary {:on-click #(dispatch [:toggle-follow-user username]) 309 | :class (when (:toggle-follow-user loading) "disabled")} 310 | [:i {:class (if following "ion-minus-round" "ion-plus-round")}] 311 | [:span (if following (str " Unfollow " username) (str " Follow " username))]])]]]] 312 | [:div.container 313 | [:div.row 314 | [:div.col-xs-12.col-md-10.offset-md-1 315 | [:div.articles-toggle 316 | [:ul.nav.nav-pills.outline-active 317 | [:li.nav-item 318 | [:a.nav-link {:href (url-for :profile :user-id username) :class (when author " active")} "My Articles"]] 319 | [:li.nav-item 320 | [:a.nav-link {:href (url-for :favorited :user-id username) :class (when favorites "active")} "Favorited Articles"]]]] 321 | [articles-list articles (:articles loading)]]]]])) 322 | 323 | ;; -- Settings ---------------------------------------------------------------- 324 | ;; 325 | (defn settings 326 | [] 327 | (let [{:keys [bio email image username] :as user} @(subscribe [:user]) 328 | default {:bio bio :email email :image image :username username} 329 | loading @(subscribe [:loading]) 330 | user-update (reagent/atom default) 331 | logout-user (fn [event] 332 | (.preventDefault event) 333 | (dispatch [:logout])) 334 | update-user (fn [event update] 335 | (.preventDefault event) 336 | (dispatch [:update-user update]))] 337 | [:div.settings-page 338 | [:div.container.page 339 | [:div.row 340 | [:div.col-md-6.offset-md-3.col-xs-12 341 | [:h1.text-xs-center "Your Settings"] 342 | [:form 343 | [:fieldset 344 | [:fieldset.form-group 345 | [:input.form-control {:type "text" 346 | :placeholder "URL of profile picture" 347 | :default-value (:image user) 348 | :on-change #(swap! user-update assoc :image (-> % .-target .-value))}]] 349 | [:fieldset.form-group 350 | [:input.form-control.form-control-lg {:type "text" 351 | :placeholder "Your Name" 352 | :default-value (:username user) 353 | :on-change #(swap! user-update assoc :username (-> % .-target .-value)) 354 | :disabled (when (:update-user loading))}]] 355 | [:fieldset.form-group 356 | [:textarea.form-control.form-control-lg {:rows "8" 357 | :placeholder "Short bio about you" 358 | :default-value (:bio user) 359 | :on-change #(swap! user-update assoc :bio (-> % .-target .-value)) 360 | :disabled (when (:update-user loading))}]] 361 | [:fieldset.form-group 362 | [:input.form-control.form-control-lg {:type "text" 363 | :placeholder "Email" 364 | :default-value (:email user) 365 | :on-change #(swap! user-update assoc :email (-> % .-target .-value)) 366 | :disabled (when (:update-user loading))}]] 367 | [:fieldset.form-group 368 | [:input.form-control.form-control-lg {:type "password" 369 | :placeholder "Password" 370 | :default-value "" 371 | :on-change #(swap! user-update assoc :password (-> % .-target .-value)) 372 | :disabled (when (:update-user loading))}]] 373 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:on-click #(update-user % @user-update) 374 | :class (when (:update-user loading) "disabled")} "Update Settings"]]] 375 | [:hr] 376 | [:button.btn.btn-outline-danger {:on-click #(logout-user %)} "Or click here to logout."]]]]])) 377 | 378 | ;; -- Editor ------------------------------------------------------------------ 379 | ;; 380 | (defn editor 381 | [] 382 | (let [{:keys [title description body tagList slug] :as active-article} @(subscribe [:active-article]) 383 | tagList (join " " tagList) 384 | default {:title title :description description :body body :tagList tagList} 385 | content (reagent/atom default) 386 | upsert-article (fn [event content slug] 387 | (.preventDefault event) 388 | (dispatch [:upsert-article {:slug slug 389 | :article {:title (trim (or (:title content) "")) 390 | :description (trim (or (:description content) "")) 391 | :body (trim (or (:body content) "")) 392 | :tagList (split (:tagList content) #" ")}}]))] 393 | (fn [] 394 | (let [errors @(subscribe [:errors])] 395 | [:div.editor-page 396 | [:div.container.page 397 | [:div.row 398 | [:div.col-md-10.offset-md-1.col-xs-12 399 | (when (:upsert-article errors) 400 | [errors-list (:upsert-article errors)]) 401 | [:form 402 | [:fieldset 403 | [:fieldset.form-group 404 | [:input.form-control.form-control-lg {:type "text" 405 | :placeholder "Article Title" 406 | :default-value title 407 | :on-change #(swap! content assoc :title (-> % .-target .-value))}]] 408 | [:fieldset.form-group 409 | [:input.form-control {:type "text" 410 | :placeholder "What's this article about?" 411 | :default-value description 412 | :on-change #(swap! content assoc :description (-> % .-target .-value))}]] 413 | [:fieldset.form-group 414 | [:textarea.form-control {:rows "8" 415 | :placeholder "Write your article (in markdown)" 416 | :default-value body 417 | :on-change #(swap! content assoc :body (-> % .-target .-value))}]] 418 | [:fieldset.form-group 419 | [:input.form-control {:type "text" 420 | :placeholder "Enter tags" 421 | :default-value tagList 422 | :on-change #(swap! content assoc :tagList (-> % .-target .-value))}] 423 | [:div.tag-list]] 424 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:on-click #(upsert-article % @content slug)} 425 | (if active-article 426 | "Update Article" 427 | "Publish Article")]]]]]]])))) 428 | 429 | ;; -- Article ----------------------------------------------------------------- 430 | ;; 431 | (defn article 432 | [] 433 | (let [default {:body ""} 434 | comment (reagent/atom default) 435 | post-comment (fn [event comment default] 436 | (.preventDefault event) 437 | (dispatch [:post-comment {:body (get @comment :body)}]) 438 | (reset! comment default))] 439 | (fn [] 440 | (let [active-article @(subscribe [:active-article]) 441 | user @(subscribe [:user]) 442 | comments @(subscribe [:comments]) 443 | errors @(subscribe [:errors]) 444 | loading @(subscribe [:loading])] 445 | [:div.article-page 446 | [:div.banner 447 | [:div.container 448 | [:h1 (:title active-article)] 449 | [article-meta active-article]]] ;; defined in Helpers section 450 | [:div.container.page 451 | [:div.row.article-content 452 | [:div.col-md-12 453 | [:p (:body active-article)]]] 454 | [tags-list (:tagList active-article)] ;; defined in Helpers section 455 | [:hr] 456 | [:div.article-actions 457 | [article-meta active-article]] ;; defined in Helpers section 458 | [:div.row 459 | [:div.col-xs-12.col-md-8.offset-md-2 460 | (when (:comments errors) 461 | [errors-list (:comments errors)]) ;; defined in Helpers section 462 | (if-not (empty? user) 463 | [:form.card.comment-form 464 | [:div.card-block 465 | [:textarea.form-control {:placeholder "Write a comment..." 466 | :rows "3" 467 | :value (:body @comment) 468 | :on-change #(swap! comment assoc :body (-> % .-target .-value))}]] 469 | [:div.card-footer 470 | [:img.comment-author-img {:src (:image user) :alt "user image"}] 471 | [:button.btn.btn-sm.btn-primary {:class (when (:comments loading) "disabled") 472 | :on-click #(post-comment % comment default)} "Post Comment"]]] 473 | [:p 474 | [:a {:href (url-for :register)} "Sign up"] 475 | " or " 476 | [:a {:href (url-for :login)} "Sign in"] 477 | " to add comments on this article."]) 478 | (if (:comments loading) 479 | [:div 480 | [:p "Loading comments ..."]] 481 | (if (empty? comments) 482 | [:div] 483 | (for [{:keys [id createdAt body author]} comments] 484 | ^{:key id} [:div.card 485 | [:div.card-block 486 | [:p.card-text body]] 487 | [:div.card-footer 488 | [:a.comment-author {:href (url-for :profile :user-id (:username author))} 489 | [:img.comment-author-img {:src (:image author) :alt "user image"}]] 490 | " " 491 | [:a.comment-author {:href (url-for :profile :user-id (:username author))} (:username author)] 492 | [:span.date-posted (format-date createdAt)] 493 | (when (= (:username user) (:username author)) 494 | [:span.mod-options {:on-click #(dispatch [:delete-comment id])} 495 | [:i.ion-trash-a]])]])))]]]])))) 496 | 497 | (defn pages [page-name] 498 | (case page-name 499 | :home [home] 500 | :login [login] 501 | :register [register] 502 | :profile [profile] 503 | :settings [settings] 504 | :editor [editor] 505 | :article [article] 506 | [home])) 507 | 508 | (defn conduit-app 509 | [] 510 | (let [active-page @(subscribe [:active-page])] 511 | [:div 512 | [header] 513 | [pages active-page] 514 | [footer]])) 515 | -------------------------------------------------------------------------------- /src/conduit/events.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.events 2 | (:require 3 | [conduit.db :refer [default-db set-user-ls remove-user-ls]] 4 | [re-frame.core :refer [reg-event-db reg-event-fx reg-fx inject-cofx trim-v after path]] 5 | [conduit.router :as router] 6 | [day8.re-frame.http-fx] ;; even if we don't use this require its existence will cause the :http-xhrio effect handler to self-register with re-frame 7 | [ajax.core :refer [json-request-format json-response-format]] 8 | [clojure.string :as str])) 9 | 10 | ;; -- Interceptors -------------------------------------------------------------- 11 | ;; Every event handler can be "wrapped" in a chain of interceptors. Each of these 12 | ;; interceptors can do things "before" and/or "after" the event handler is executed. 13 | ;; They are like the "middleware" of web servers, wrapping around the "handler". 14 | ;; Interceptors are a useful way of factoring out commonality (across event 15 | ;; handlers) and looking after cross-cutting concerns like logging or validation. 16 | ;; 17 | ;; They are also used to "inject" values into the `coeffects` parameter of 18 | ;; an event handler, when that handler needs access to certain resources. 19 | ;; 20 | ;; Each event handler can have its own chain of interceptors. Below we create 21 | ;; the interceptor chain shared by all event handlers which manipulate user. 22 | ;; A chain of interceptors is a vector. 23 | ;; Explanation of `trim-v` is given further below. 24 | ;; 25 | (def set-user-interceptor [(path :user) ;; `:user` path within `db`, rather than the full `db`. 26 | (after set-user-ls) ;; write user to localstore (after) 27 | trim-v]) ;; removes first (event id) element from the event vec 28 | 29 | ;; After logging out clean up local-storage so that when a users refreshes 30 | ;; the browser she/he is not automatically logged-in, and because it's a 31 | ;; good practice to clean-up after yourself. 32 | ;; 33 | (def remove-user-interceptor [(after remove-user-ls)]) 34 | 35 | ;; -- Helpers ----------------------------------------------------------------- 36 | ;; 37 | (def api-url "https://conduit.productionready.io/api") 38 | 39 | (defn endpoint 40 | "Concat any params to api-url separated by /" 41 | [& params] 42 | (str/join "/" (concat [api-url] params))) 43 | 44 | (defn auth-header 45 | "Get user token and format for API authorization" 46 | [db] 47 | (when-let [token (get-in db [:user :token])] 48 | [:Authorization (str "Token " token)])) 49 | 50 | (defn add-epoch 51 | "Takes date identifier and adds :epoch (cljs-time.coerce/to-long) timestamp to coll" 52 | [date coll] 53 | (map (fn [item] (assoc item :epoch (.getTime (js/Date.) date))) coll)) 54 | 55 | (defn index-by 56 | "Transform a coll to a map with a given key as a lookup value" 57 | [key coll] 58 | (into {} (map (juxt key identity) (add-epoch :createdAt coll)))) 59 | 60 | (reg-fx 61 | :set-url 62 | (fn [{:keys [url]}] 63 | (router/set-token! url))) 64 | 65 | ;; -- Event Handlers ---------------------------------------------------------- 66 | ;; 67 | (reg-event-fx ;; usage: (dispatch [:initialise-db]) 68 | :initialise-db ;; sets up initial application state 69 | 70 | ;; the interceptor chain (a vector of interceptors) 71 | [(inject-cofx :local-store-user)] ;; gets user from localstore, and puts into coeffects arg 72 | 73 | ;; the event handler (function) being registered 74 | (fn [{:keys [local-store-user]} _] ;; take 2 vals from coeffects. Ignore event vector itself. 75 | {:db (assoc default-db :user local-store-user)})) ;; what it returns becomes the new application state 76 | 77 | (reg-event-fx ;; usage: (dispatch [:set-active-page {:page :home}) 78 | :set-active-page ;; triggered when the user clicks on a link that redirects to a another page 79 | (fn [{:keys [db]} [_ {:keys [page slug profile favorited]}]] ;; destructure 2nd parameter to obtain keys 80 | (let [set-page (assoc db :active-page page)] 81 | (case page 82 | ;; -- URL @ "/" -------------------------------------------------------- 83 | :home {:db set-page 84 | :dispatch-n [(if (empty? (:user db)) ;; dispatch more than one event. When a user 85 | [:get-articles {:limit 10}] ;; is NOT logged in we display all articles 86 | [:get-feed-articles {:limit 10}]) ;; otherwiser we get her/his feed articles 87 | [:get-tags]]} ;; we also can't forget to get tags 88 | 89 | ;; -- URL @ "/login" | "/register" | "/settings" ----------------------- 90 | (:login :register :settings) {:db set-page} ;; when using case with multiple test constants that 91 | ;; do the same thing we can group them together 92 | ;; (:login :register :settings) {:db set-page} is the same as: 93 | ;; :login {:db set-page} 94 | ;; :register {:db set-page} 95 | ;; :settings {:db set-page} 96 | ;; -- URL @ "/editor" -------------------------------------------------- 97 | :editor {:db set-page 98 | :dispatch (if slug ;; When we click article to edit we need 99 | [:set-active-article slug] ;; to set it active or if we want to write 100 | [:reset-active-article])} ;; a new article we reset 101 | 102 | ;; -- URL @ "/article/:slug" ------------------------------------------- 103 | :article {:db (assoc set-page :active-article slug) 104 | :dispatch-n [[:get-articles {:limit 10}] 105 | [:get-article-comments {:slug slug}] 106 | [:get-user-profile {:profile (get-in db [:articles slug :author :username])}]]} 107 | 108 | ;; -- URL @ "/profile/:slug" ------------------------------------------- 109 | :profile {:db (assoc set-page :active-article slug) 110 | :dispatch-n [[:get-user-profile {:profile profile}] ;; again for dispatching multiple 111 | [:get-articles {:author profile}]]} ;; events we can use :dispatch-n 112 | ;; -- URL @ "/profile/:slug/favorites" --------------------------------- 113 | :favorited {:db (assoc db :active-page :profile) ;; even though we are at :favorited we still 114 | :dispatch [:get-articles {:favorited favorited}]})))) ;; display :profile with :favorited articles 115 | 116 | (reg-event-db ;; usage: (dispatch [:reset-active-article]) 117 | :reset-active-article ;; triggered when the user enters new-article i.e. editor without slug 118 | (fn [db _] ;; 1st paramter in -db events is db, 2nd paramter not important therefore _ 119 | (dissoc db :active-article))) ;; compute and return the new state 120 | 121 | (reg-event-fx ;; usage: (dispatch [:set-active-article slug]) 122 | :set-active-article 123 | (fn [{:keys [db]} [_ slug]] ;; 1st parameter in -fx events is no longer just db. It is a map which contains a :db key. 124 | {:db (assoc db :active-article slug) ;; The handler is returning a map which describes two side-effects: 125 | :dispatch-n [[:get-article-comments {:slug slug}] ;; changne to app-state :db and future event in this case :dispatch-n 126 | [:get-user-profile {:profile (get-in db [:articles slug :author :username])}]]})) 127 | 128 | ;; -- GET Articles @ /api/articles -------------------------------------------- 129 | ;; 130 | (reg-event-fx ;; usage (dispatch [:get-articles {:limit 10 :tag "tag-name" ...}]) 131 | :get-articles ;; triggered every time user request articles with differetn params 132 | (fn [{:keys [db]} [_ params]] ;; params = {:limit 10 :tag "tag-name" ...} 133 | {:http-xhrio {:method :get 134 | :uri (endpoint "articles") ;; evaluates to "api/articles/" 135 | :params params ;; include params in the request 136 | :headers (auth-header db) ;; get and pass user token obtained during login 137 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 138 | :on-success [:get-articles-success] ;; trigger get-articles-success event 139 | :on-failure [:api-request-error :get-articles]} ;; trigger api-request-error with :get-articles 140 | :db (-> db 141 | (assoc-in [:loading :articles] true) 142 | (assoc-in [:filter :offset] (:offset params)) ;; base on paassed params set a filter 143 | (assoc-in [:filter :tag] (:tag params)) ;; so that we can easily show and hide 144 | (assoc-in [:filter :author] (:author params)) ;; appropriate ui components 145 | (assoc-in [:filter :favorites] (:favorited params)) 146 | (assoc-in [:filter :feed] false))})) ;; we need to disable filter by feed every time since it's not supported query param 147 | 148 | (reg-event-db 149 | :get-articles-success 150 | (fn [db [_ {articles :articles, articles-count :articlesCount}]] 151 | (-> db 152 | (assoc-in [:loading :articles] false) ;; turn off loading flag for this event 153 | (assoc :articles-count articles-count ;; change app-state by adding articles-count 154 | :articles (index-by :slug articles))))) ;; and articles, which we index-by slug 155 | 156 | ;; -- GET Article @ /api/articles/:slug --------------------------------------- 157 | ;; 158 | (reg-event-fx ;; usage (dispatch [:get-article {:slug "slug"}]) 159 | :get-article ;; triggered when a user upserts article i.e. is redirected to article page after saving an article 160 | (fn [{:keys [db]} [_ params]] ;; params = {:slug "slug"} 161 | {:http-xhrio {:method :get 162 | :uri (endpoint "articles" (:slug params)) ;; evaluates to "api/articles/:slug" 163 | :headers (auth-header db) ;; get and pass user token obtained during login 164 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 165 | :on-success [:get-article-success] ;; trigger get-article-success event 166 | :on-failure [:api-request-error :get-article]} ;; trigger api-request-error with :get-articles 167 | :db (assoc-in db [:loading :article] true)})) 168 | 169 | (reg-event-db 170 | :get-article-success 171 | (fn [db [_ {article :article}]] 172 | (-> db 173 | (assoc-in [:loading :article] false) 174 | (assoc :articles (index-by :slug [article]))))) 175 | 176 | ;; -- POST/PUT Article @ /api/articles(/:slug) -------------------------------- 177 | ;; 178 | (reg-event-fx ;; usage (dispatch [:upsert-article article]) 179 | :upsert-article ;; when we update or insert (upsert) we are sending the same shape of information 180 | (fn [{:keys [db]} [_ params]] ;; params = {:slug "article-slug" :article {:body "article body"} } 181 | {:db (assoc-in db [:loading :article] true) 182 | :http-xhrio {:method (if (:slug params) :put :post) ;; when we get a slug we'll update (:put) otherwise insert (:post) 183 | :uri (if (:slug params) ;; Same logic as above but we go with different 184 | (endpoint "articles" (:slug params)) ;; endpoint - one with :slug to update 185 | (endpoint "articles")) ;; and another to insert 186 | :headers (auth-header db) ;; get and pass user token obtained during login 187 | :params {:article (:article params)} 188 | :format (json-request-format) ;; make sure we are doing request format wiht json 189 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 190 | :on-success [:upsert-article-success] ;; trigger upsert-article-success event 191 | :on-failure [:api-request-error :upsert-article]}})) ;; trigger api-request-error with :upsert-article 192 | 193 | (reg-event-fx 194 | :upsert-article-success 195 | (fn [{:keys [db]} [_ {article :article}]] 196 | {:db (-> db 197 | (assoc-in [:loading :article] false) 198 | (dissoc :comments) ;; clean up any comments that we might have in db 199 | (dissoc :errors) ;; clean up any erros that we might have in db 200 | (assoc :active-page :article 201 | :active-article (:slug article))) 202 | :dispatch-n [[:get-article {:slug (:slug article)}] ;; when the users clicks save we fetch the new version 203 | [:get-article-comments {:slug (:slug article)}]] ;; of the article and comments from the server 204 | :set-url {:url (str "/article/" (:slug article))}})) 205 | 206 | ;; -- DELETE Article @ /api/articles/:slug ------------------------------------ 207 | ;; 208 | (reg-event-fx ;; usage (dispatch [:delete-article slug]) 209 | :delete-article ;; triggered when a user deletes an article 210 | (fn [{:keys [db]} [_ slug]] ;; slug = {:slug "article-slug"} 211 | {:db (assoc-in db [:loading :article] true) 212 | :http-xhrio {:method :delete 213 | :uri (endpoint "articles" slug) ;; evaluates to "api/articles/:slug" 214 | :headers (auth-header db) ;; get and pass user token obtained during login 215 | :params slug ;; pass the article slug to delete 216 | :format (json-request-format) ;; make sure we are doing request format wiht json 217 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 218 | :on-success [:delete-article-success] ;; trigger get-articles-success 219 | :on-failure [:api-request-error :delete-article]}})) ;; trigger api-request-error with :delete-article 220 | 221 | (reg-event-fx 222 | :delete-article-success 223 | (fn [{:keys [db]} _] 224 | {:db (-> db 225 | (update-in [:articles] dissoc (:active-article db)) 226 | (assoc-in [:loading :article] false)) 227 | :dispatch [:set-active-page {:page :home}]})) 228 | 229 | ;; -- GET Feed Articles @ /api/articles/feed ---------------------------------- 230 | ;; 231 | (reg-event-fx ;; usage (dispatch [:get-feed-articles {:limit 10 :offset 0 ...}]) 232 | :get-feed-articles ;; triggered when Your Feed tab is loaded 233 | (fn [{:keys [db]} [_ params]] ;; params = {:offset 0 :limit 10} 234 | {:http-xhrio {:method :get 235 | :uri (endpoint "articles" "feed") ;; evaluates to "api/articles/feed" 236 | :params params ;; include params in the request 237 | :headers (auth-header db) ;; get and pass user token obtained during login 238 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 239 | :on-success [:get-feed-articles-success] ;; trigger get-articles-success event 240 | :on-failure [:api-request-error :get-feed-articles]} ;; trigger api-request-error with :get-feed-articles 241 | :db (-> db 242 | (assoc-in [:loading :articles] true) 243 | (assoc-in [:filter :offset] (:offset params)) 244 | (assoc-in [:filter :tag] nil) ;; with feed-articles we turn off almost all 245 | (assoc-in [:filter :author] nil) ;; filters to make sure everythinig on the 246 | (assoc-in [:filter :favorites] nil) ;; client is displayed correctly. 247 | (assoc-in [:filter :feed] true))})) ;; This is the only one we need 248 | 249 | (reg-event-db 250 | :get-feed-articles-success 251 | (fn [db [_ {articles :articles, articles-count :articlesCount}]] 252 | (-> db 253 | (assoc-in [:loading :articles] false) 254 | (assoc :articles-count articles-count 255 | :articles (index-by :slug articles))))) 256 | 257 | ;; -- GET Tags @ /api/tags ---------------------------------------------------- 258 | ;; 259 | (reg-event-fx ;; usage (dispatch [:get-tags]) 260 | :get-tags ;; triggered when the home page is loaded 261 | (fn [{:keys [db]} _] ;; second parameter is not important, therefore _ 262 | {:db (assoc-in db [:loading :tags] true) 263 | :http-xhrio {:method :get 264 | :uri (endpoint "tags") ;; evaluates to "api/tags" 265 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 266 | :on-success [:get-tags-success] ;; trigger get-tags-success event 267 | :on-failure [:api-request-error :get-tags]}})) ;; trigger api-request-error with :get-tags 268 | 269 | (reg-event-db 270 | :get-tags-success 271 | (fn [db [_ {tags :tags}]] 272 | (-> db 273 | (assoc-in [:loading :tags] false) 274 | (assoc :tags tags)))) 275 | 276 | ;; -- GET Comments @ /api/articles/:slug/comments ----------------------------- 277 | ;; 278 | (reg-event-fx ;; usage (dispatch [:get-article-comments {:slug "article-slug"}]) 279 | :get-article-comments ;; triggered when the article page is loaded 280 | (fn [{:keys [db]} [_ params]] ;; params = {:slug "article-slug"} 281 | {:db (assoc-in db [:loading :comments] true) 282 | :http-xhrio {:method :get 283 | :uri (endpoint "articles" (:slug params) "comments") ;; evaluates to "api/articles/:slug/comments" 284 | :headers (auth-header db) ;; get and pass user token obtained during login 285 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 286 | :on-success [:get-article-comments-success] ;; trigger get-article-comments-success 287 | :on-failure [:api-request-error :get-article-comments]}})) ;; trigger api-request-error with :get-article-comments 288 | 289 | (reg-event-db 290 | :get-article-comments-success 291 | (fn [db [_ {comments :comments}]] 292 | (-> db 293 | (assoc-in [:loading :comments] false) 294 | (assoc :comments (index-by :id comments))))) ;; another index-by, this time by id 295 | 296 | ;; -- POST Comments @ /api/articles/:slug/comments ---------------------------- 297 | ;; 298 | (reg-event-fx ;; usage (dispatch [:post-comment comment]) 299 | :post-comment ;; triggered when a user submits a comment 300 | (fn [{:keys [db]} [_ body]] ;; body = {:body "body" } 301 | {:db (assoc-in db [:loading :comments] true) 302 | :http-xhrio {:method :post 303 | :uri (endpoint "articles" (:active-article db) "comments") ;; evaluates to "api/articles/:slug/comments" 304 | :headers (auth-header db) ;; get and pass user token obtained during login 305 | :params {:comment body} 306 | :format (json-request-format) ;; make sure we are doing request format wiht json 307 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 308 | :on-success [:post-comment-success] ;; trigger get-articles-success 309 | :on-failure [:api-request-error :comments]}})) ;; trigger api-request-error with :comments 310 | 311 | (reg-event-fx 312 | :post-comment-success 313 | (fn [{:keys [db]} [_ comment]] 314 | {:db (-> db 315 | (assoc-in [:loading :comments] false) 316 | (assoc-in [:articles (:active-article db) :comments] comment) 317 | (update-in [:errors] dissoc :comments)) ;; clean up errors, if any 318 | :dispatch [:get-article-comments {:slug (:active-article db)}]})) 319 | 320 | ;; -- DELETE Comments @ /api/articles/:slug/comments/:comment-id ---------------------- 321 | ;; 322 | (reg-event-fx ;; usage (dispatch [:delete-comment comment-id]) 323 | :delete-comment ;; triggered when a user deletes an article 324 | (fn [{:keys [db]} [_ comment-id]] ;; comment-id = 1234 325 | {:db (-> db 326 | (assoc-in [:loading :comments] true) 327 | (assoc :active-comment comment-id)) 328 | :http-xhrio {:method :delete 329 | :uri (endpoint "articles" (:active-article db) "comments" comment-id) ;; evaluates to "api/articles/:slug/comments/:comment-id" 330 | :headers (auth-header db) ;; get and pass user token obtained during login 331 | :format (json-request-format) ;; make sure we are doing request format wiht json 332 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 333 | :on-success [:delete-comment-success] ;; trigger delete-comment-success 334 | :on-failure [:api-request-error :delete-comment]}})) ;; trigger api-request-error with :delete-comment 335 | 336 | (reg-event-db 337 | :delete-comment-success 338 | (fn [db _] 339 | (-> db 340 | (update-in [:comments] dissoc (:active-comment db)) ;; we could do another fetch of comments 341 | (dissoc :active-comment) ;; but instead we just remove it from app-db 342 | (assoc-in [:loading :comment] false)))) ;; which gives us much snappier ui 343 | 344 | ;; -- GET Profile @ /api/profiles/:username ----------------------------------- 345 | ;; 346 | (reg-event-fx ;; usage (dispatch [:get-user-profile {:profile "profile"}]) 347 | :get-user-profile ;; triggered when the profile page is loaded 348 | (fn [{:keys [db]} [_ params]] ;; params = {:profile "profile"} 349 | {:db (assoc-in db [:loading :profile] true) 350 | :http-xhrio {:method :get 351 | :uri (endpoint "profiles" (:profile params)) ;; evaluates to "api/profiles/:profile" 352 | :headers (auth-header db) ;; get and pass user token obtained during login 353 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 354 | :on-success [:get-user-profile-success] ;; trigger get-user-profile-success 355 | :on-failure [:api-request-error :get-user-profile]}})) ;; trigger api-request-error with :get-user-profile 356 | 357 | (reg-event-db 358 | :get-user-profile-success 359 | (fn [db [_ {profile :profile}]] 360 | (-> db 361 | (assoc-in [:loading :profile] false) 362 | (assoc :profile profile)))) 363 | 364 | ;; -- POST Login @ /api/users/login ------------------------------------------- 365 | ;; 366 | (reg-event-fx ;; usage (dispatch [:login user]) 367 | :login ;; triggered when a users submits login form 368 | (fn [{:keys [db]} [_ credentials]] ;; credentials = {:email ... :password ...} 369 | {:db (assoc-in db [:loading :login] true) 370 | :http-xhrio {:method :post 371 | :uri (endpoint "users" "login") ;; evaluates to "api/users/login" 372 | :params {:user credentials} ;; {:user {:email ... :password ...}} 373 | :format (json-request-format) ;; make sure it's json 374 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 375 | :on-success [:login-success] ;; trigger login-success 376 | :on-failure [:api-request-error :login]}})) ;; trigger api-request-error with :login 377 | 378 | (reg-event-fx 379 | :login-success 380 | ;; The standard set of interceptors, defined above, which we 381 | ;; use for all user-modifying event handlers. Looks after 382 | ;; writing user to localStorage. 383 | ;; NOTE: this chain includes `path` and `trim-v` 384 | set-user-interceptor 385 | 386 | ;; The event handler function. 387 | ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the 388 | ;; value at `:user` path within `db`, rather than the full `db`. 389 | ;; And, further, it means the event handler returns just the value to be 390 | ;; put into `:user` path, and not the entire `db`. 391 | ;; So, a path interceptor makes the event handler act more like clojure's `update-in` 392 | (fn [{user :db} [{props :user}]] 393 | {:db (merge user props) 394 | :dispatch-n [[:get-feed-articles {:tag nil :author nil :offset 0 :limit 10}] 395 | [:set-active-page {:page :home}]]})) 396 | 397 | ;; -- POST Registration @ /api/users ------------------------------------------ 398 | ;; 399 | (reg-event-fx ;; usage (dispatch [:register-user registration]) 400 | :register-user ;; triggered when a users submits registration form 401 | (fn [{:keys [db]} [_ registration]] ;; registration = {:username ... :email ... :password ...} 402 | {:db (assoc-in db [:loading :register-user] true) 403 | :http-xhrio {:method :post 404 | :uri (endpoint "users") ;; evaluates to "api/users" 405 | :params {:user registration} ;; {:user {:username ... :email ... :password ...}} 406 | :format (json-request-format) ;; make sure it's json 407 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 408 | :on-success [:register-user-success] ;; trigger login-success 409 | :on-failure [:api-request-error :register-user]}})) ;; trigger api-request-error with :login-success 410 | 411 | (reg-event-fx 412 | :register-user-success 413 | ;; The standard set of interceptors, defined above, which we 414 | ;; use for all user-modifying event handlers. Looks after 415 | ;; writing user to LocalStore. 416 | ;; NOTE: this chain includes `path` and `trim-v` 417 | set-user-interceptor 418 | 419 | ;; The event handler function. 420 | ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the 421 | ;; value at `:user` path within `db`, rather than the full `db`. 422 | ;; And, further, it means the event handler returns just the value to be 423 | ;; put into `:user` path, and not the entire `db`. 424 | ;; So, a path interceptor makes the event handler act more like clojure's `update-in` 425 | (fn [{user :db} [{props :user}]] 426 | {:db (merge user props) 427 | :dispatch [:set-active-page {:page :home}]})) 428 | 429 | ;; -- PUT Update User @ /api/user --------------------------------------------- 430 | ;; 431 | (reg-event-fx ;; usage (dispatch [:update-user user]) 432 | :update-user ;; triggered when a users updates settgins 433 | (fn [{:keys [db]} [_ user]] ;; user = {:img ... :username ... :bio ... :email ... :password ...} 434 | {:db (assoc-in db [:loading :update-user] true) 435 | :http-xhrio {:method :put 436 | :uri (endpoint "user") ;; evaluates to "api/user" 437 | :params {:user user} ;; {:user {:img ... :username ... :bio ... :email ... :password ...}} 438 | :headers (auth-header db) ;; get and pass user token obtained during login 439 | :format (json-request-format) ;; make sure our request is json 440 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 441 | :on-success [:update-user-success] ;; trigger update-user-success 442 | :on-failure [:api-request-error :update-user]}})) ;; trigger api-request-error with :update-user 443 | 444 | (reg-event-fx 445 | :update-user-success 446 | ;; The standard set of interceptors, defined above, which we 447 | ;; use for all user-modifying event handlers. Looks after 448 | ;; writing user to LocalStore. 449 | ;; NOTE: this chain includes `path` and `trim-v` 450 | set-user-interceptor 451 | 452 | ;; The event handler function. 453 | ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the 454 | ;; value at `:user` path within `db`, rather than the full `db`. 455 | ;; And, further, it means the event handler returns just the value to be 456 | ;; put into `:user` path, and not the entire `db`. 457 | ;; So, a path interceptor makes the event handler act more like clojure's `update-in` 458 | (fn [{user :db} [{props :user}]] 459 | {:db (merge user props)})) 460 | 461 | ;; -- Toggle follow user @ /api/profiles/:username/follow --------------------- 462 | ;; 463 | (reg-event-fx ;; usage (dispatch [:toggle-follow-user username]) 464 | :toggle-follow-user ;; triggered when user clicks follow/unfollow button on profile page 465 | (fn [{:keys [db]} [_ username]] ;; username = :username 466 | {:db (assoc-in db [:loading :toggle-follow-user] true) 467 | :http-xhrio {:method (if (get-in db [:profile :following]) :delete :post) ;; check if we follow if yes DELETE, no POST 468 | :uri (endpoint "profiles" username "follow") ;; evaluates to "api/profiles/:username/follow" 469 | :headers (auth-header db) ;; get and pass user token obtained during login 470 | :format (json-request-format) ;; make sure it's json 471 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 472 | :on-success [:toggle-follow-user-success] ;; trigger toggle-follow-user-success 473 | :on-failure [:api-request-error :login]}})) ;; trigger api-request-error with :update-user-success 474 | 475 | (reg-event-db ;; usage: (dispatch [:toggle-follow-user-success]) 476 | :toggle-follow-user-success 477 | (fn [db [_ {profile :profile}]] 478 | (-> db 479 | (assoc-in [:loading :toggle-follow-user] false) 480 | (assoc-in [:profile :following] (:following profile))))) 481 | 482 | ;; -- Toggle favorite article @ /api/articles/:slug/favorite ------------------ 483 | ;; 484 | (reg-event-fx ;; usage (dispatch [:toggle-favorite-article slug]) 485 | :toggle-favorite-article ;; triggered when user clicks favorite/unfavorite button on profile page 486 | (fn [{:keys [db]} [_ slug]] ;; slug = :slug 487 | {:db (assoc-in db [:loading :toggle-favorite-article] true) 488 | :http-xhrio {:method (if (get-in db [:articles slug :favorited]) :delete :post) ;; check if article is already favorite: yes DELETE, no POST 489 | :uri (endpoint "articles" slug "favorite") ;; evaluates to "api/articles/:slug/favorite" 490 | :headers (auth-header db) ;; get and pass user token obtained during login 491 | :format (json-request-format) ;; make sure it's json 492 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 493 | :on-success [:toggle-favorite-article-success] ;; trigger toggle-favorite-article-success 494 | :on-failure [:api-request-error :login]}})) ;; trigger api-request-error with :toggle-favorite-article 495 | 496 | (reg-event-db ;; usage: (dispatch [:toggle-favorite-article-success]) 497 | :toggle-favorite-article-success 498 | (fn [db [_ {article :article}]] 499 | (let [slug (:slug article) 500 | favorited (:favorited article)] 501 | (-> db 502 | (assoc-in [:loading :toggle-favorite-article] false) 503 | (assoc-in [:articles slug :favorited] favorited) 504 | (assoc-in [:articles slug :favoritesCount] (if favorited 505 | (:favoritesCount article inc) 506 | (:favoritesCount article dec))))))) 507 | 508 | ;; -- Logout ------------------------------------------------------------------ 509 | ;; 510 | (reg-event-fx ;; usage (dispatch [:logout]) 511 | :logout 512 | ;; This interceptor, defined above, makes sure 513 | ;; that we clean up localStorage after logging-out 514 | ;; the user. 515 | remove-user-interceptor 516 | ;; The event handler function removes the user from 517 | ;; app-state = :db and sets the url to "/". 518 | (fn [{:keys [db]} _] 519 | {:db (dissoc db :user) ;; remove user from db 520 | :dispatch [:set-active-page {:page :home}]})) 521 | 522 | ;; -- Request Handlers ----------------------------------------------------------- 523 | ;; 524 | 525 | (reg-event-db 526 | :api-request-error ; triggered when we get request-error from the server 527 | (fn [db [_ request-type response]] ;; destructure to obtain request-type and response 528 | (-> db ;; when we complete a request we need to clean so that our ui is nice and tidy 529 | (assoc-in [:errors request-type] (get-in response [:response :errors])) 530 | (assoc-in [:loading request-type] false)))) 531 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 566 | --------------------------------------------------------------------------------