├── .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 |
6 |
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 | # 
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 |
399 |
400 |
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
451 |
452 |
453 |
454 |
455 |
456 |
457 |
458 |
459 |
460 |
461 |
462 |
463 |
464 |
465 |
466 |
467 |
468 |
469 |
470 |
471 |
472 |
473 |
474 |
475 |
476 |
477 |
478 |
479 |
480 |
481 |
482 |
483 |
484 |
485 |
486 |
487 |
488 |
489 |
490 |
491 |
492 |
493 |
494 |
495 |
496 |
497 |
498 |
499 |
500 |
501 |
502 |
503 |
504 |
505 |
506 |
507 |
508 |
509 |
510 |
511 |
512 |
513 |
514 |
515 |
516 |
517 |
518 |
519 |
520 |
521 |
522 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
530 |
531 |
532 |
533 |
534 |
535 |
536 |
537 |
538 |
539 |
540 |
541 |
542 |
543 |
544 |
545 |
546 |
547 |
548 |
549 |
550 |
551 |
552 |
553 |
554 |
555 |
556 |
557 |
558 |
559 |
560 |
561 |
562 |
563 |
564 |
565 |
566 |
--------------------------------------------------------------------------------