From 282debca284090fb32a612126f712f1e697cfb7d Mon Sep 17 00:00:00 2001 From: gered Date: Tue, 31 May 2016 14:43:49 -0400 Subject: [PATCH] update examples (switched to sente, copy of todomvc w/ browserchannel) --- examples/class-registry/README.md | 6 +- examples/class-registry/project.clj | 5 +- .../src/class_registry/client.cljs | 33 +- .../src/class_registry/server.clj | 55 +- examples/todomvc-browserchannel/.gitignore | 18 + examples/todomvc-browserchannel/README.md | 63 ++ examples/todomvc-browserchannel/create_db.sql | 21 + examples/todomvc-browserchannel/project.clj | 56 ++ .../resources/public/todos.css | 558 ++++++++++++++++++ .../resources/public/todosanim.css | 18 + .../src/todomvc/client.cljs | 180 ++++++ .../src/todomvc/server.clj | 188 ++++++ examples/todomvc/README.md | 4 +- examples/todomvc/project.clj | 5 +- examples/todomvc/src/todomvc/client.cljs | 57 +- examples/todomvc/src/todomvc/server.clj | 85 ++- 16 files changed, 1298 insertions(+), 54 deletions(-) create mode 100644 examples/todomvc-browserchannel/.gitignore create mode 100644 examples/todomvc-browserchannel/README.md create mode 100644 examples/todomvc-browserchannel/create_db.sql create mode 100644 examples/todomvc-browserchannel/project.clj create mode 100644 examples/todomvc-browserchannel/resources/public/todos.css create mode 100644 examples/todomvc-browserchannel/resources/public/todosanim.css create mode 100644 examples/todomvc-browserchannel/src/todomvc/client.cljs create mode 100644 examples/todomvc-browserchannel/src/todomvc/server.clj diff --git a/examples/class-registry/README.md b/examples/class-registry/README.md index 0560a29..f36050a 100644 --- a/examples/class-registry/README.md +++ b/examples/class-registry/README.md @@ -12,7 +12,7 @@ to the user. While the code is longer as a result, this still serves as a more interesting example with several views being used (some using parameters). -Definitely take a look at the [TodoMVC][1] example app before diving into +Definitely take a look at the [Todo MVC][1] example app before diving into this and also be sure you're familiar with Reagent. [1]: https://github.com/gered/views.reagent/tree/master/examples/todomvc @@ -27,10 +27,10 @@ you will need to first clone the following repositories and manually install the libraries via `lein install`: * [views](https://github.com/gered/views) -* [views-sql](https://github.com/gered/views-sql) +* [views.sql](https://github.com/gered/views.sql) * [views.reagent](https://github.com/gered/views.reagent) -As well, you can install [views-honeysql](https://github.com/gered/views-honeysql) +As well, you can install [views.honeysql](https://github.com/gered/views.honeysql) if you want to try out using HoneySQL instead of SQL with views. But this example app does not use it so it's not required. diff --git a/examples/class-registry/project.clj b/examples/class-registry/project.clj index a7cdbb1..0c7c060 100644 --- a/examples/class-registry/project.clj +++ b/examples/class-registry/project.clj @@ -9,12 +9,11 @@ [org.clojure/java.jdbc "0.6.1"] [org.postgresql/postgresql "9.4.1208.jre7"] - [gered/clj-browserchannel "0.3.2"] - [gered/clj-browserchannel-immutant-adapter "0.0.3"] + [com.taoensso/sente "1.8.1"] [gered/views "1.5-SNAPSHOT"] [gered/views.sql "0.1.0-SNAPSHOT"] [gered/views.reagent "0.2.0-SNAPSHOT"] - [gered/views.reagent.browserchannel "0.1.0-SNAPSHOT"] + [gered/views.reagent.sente "0.1.0-SNAPSHOT"] [hiccup "1.0.5"] [reagent "0.6.0-alpha2"] diff --git a/examples/class-registry/src/class_registry/client.cljs b/examples/class-registry/src/class_registry/client.cljs index 8029359..eb28822 100644 --- a/examples/class-registry/src/class_registry/client.cljs +++ b/examples/class-registry/src/class_registry/client.cljs @@ -3,9 +3,9 @@ [clojure.string :as string] [reagent.core :as r] [ajax.core :refer [POST default-interceptors to-interceptor]] - [net.thegeez.browserchannel.client :as browserchannel] + [taoensso.sente :as sente] [views.reagent.client.component :refer [view-cursor] :refer-macros [defvc]] - [views.reagent.browserchannel.client :as vr])) + [views.reagent.sente.client :as vr])) ;; Class Registry - views.reagent example app ;; @@ -23,6 +23,12 @@ +;; Sente socket + +(defonce sente-socket (atom {})) + + + ;; AJAX actions (defn add-person! [person] (POST "/people/add" {:params {:person person}})) @@ -372,11 +378,30 @@ +;; Sente event/message handler + +(defn sente-event-msg-handler + [{:keys [event id client-id] :as ev}] + (let [[ev-id ev-data] event] + (cond + (and (= :chsk/state ev-id) + (:open? ev-data)) + (vr/on-open! @sente-socket ev) + + (= :chsk/recv id) + (when-not (vr/on-receive! @sente-socket ev) + ; TODO: any code here needed to handle app-specific receive events + )))) + + + ;; Page load (defn ^:export run [] - (vr/init!) - (browserchannel/connect! {} {:middleware [vr/middleware]}) + (reset! sente-socket (sente/make-channel-socket! "/chsk" {})) + (sente/start-chsk-router! (:ch-recv @sente-socket) sente-event-msg-handler) + + (vr/init! @sente-socket {}) (r/render-component [class-registry-app] (.getElementById js/document "app"))) diff --git a/examples/class-registry/src/class_registry/server.clj b/examples/class-registry/src/class_registry/server.clj index 7aa2f1b..94bd04e 100644 --- a/examples/class-registry/src/class_registry/server.clj +++ b/examples/class-registry/src/class_registry/server.clj @@ -7,8 +7,8 @@ [ring.middleware.format :refer [wrap-restful-format]] [ring.util.anti-forgery :refer [anti-forgery-field]] [ring.util.response :refer [response]] - [net.thegeez.browserchannel.server :refer [wrap-browserchannel]] - [net.thegeez.browserchannel.immutant-async-adapter :refer [wrap-immutant-async-adapter]] + [taoensso.sente :as sente] + [taoensso.sente.server-adapters.immutant :refer [sente-web-server-adapter]] [immutant.web :as immutant] [hiccup.page :refer [html5 include-css include-js]] [hiccup.element :refer [javascript-tag]] @@ -16,7 +16,7 @@ [clojure.java.jdbc :as jdbc] [views.sql.core :refer [vexec! with-view-transaction]] [views.sql.view :refer [view]] - [views.reagent.browserchannel.server :as vr])) + [views.reagent.sente.server :as vr])) (def dev? (boolean (env :dev))) @@ -28,6 +28,10 @@ +;; Sente socket + +(defonce sente-socket (atom {})) + ;; View system atom (defonce view-system (atom {})) @@ -176,12 +180,42 @@ (route/resources "/") (route/not-found "not found"))) + +;; Ring middleware to intercept requests to Sente's channel socket routes +;; +;; Because our ring handler below is also using wrap-restful-format, we need to make +;; sure we catch requests intended to Sente before wrap-restful-format has a chance to +;; modify any of the request params our we'll get errors. +;; This is obviously not the only approach to handling this problem, but it is one that +;; I personally find nice, easy and flexible. + +(defn wrap-sente + [handler uri] + (fn [request] + (let [uri-match? (.startsWith (str (:uri request)) uri) + method (:request-method request)] + (cond + (and uri-match? (= :get method)) ((:ajax-get-or-ws-handshake-fn @sente-socket) request) + (and uri-match? (= :post method)) ((:ajax-post-fn @sente-socket) request) + :else (handler request))))) + (def handler (-> app-routes (wrap-restful-format :formats [:transit-json]) - (wrap-defaults (assoc-in site-defaults [:security :anti-forgery] (not dev?))) - (wrap-browserchannel {} {:middleware [(vr/->middleware view-system)]}) - (wrap-immutant-async-adapter))) + (wrap-sente "/chsk") + (wrap-defaults (assoc-in site-defaults [:security :anti-forgery] (not dev?))))) + + + +;; Sente event/message handler + +(defn sente-event-msg-handler + [{:keys [event id uid client-id] :as ev}] + (if (= id :chsk/uidport-close) + (vr/on-close! view-system ev) + (when-not (vr/on-receive! view-system ev) + ; TODO: any code here needed to handle app-specific receive events + ))) @@ -189,7 +223,14 @@ (defn run-server [] - (vr/init! view-system {:views views}) + (reset! sente-socket + (sente/make-channel-socket! + sente-web-server-adapter + {:user-id-fn (fn [request] (get-in request [:params :client-id]))})) + (sente/start-chsk-router! (:ch-recv @sente-socket) sente-event-msg-handler) + + (vr/init! view-system @sente-socket {:views views}) + (immutant/run handler {:port 8080})) (defn -main diff --git a/examples/todomvc-browserchannel/.gitignore b/examples/todomvc-browserchannel/.gitignore new file mode 100644 index 0000000..c7c1135 --- /dev/null +++ b/examples/todomvc-browserchannel/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +/target +/classes +/checkouts +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.settings/ +.project +.classpath +.idea/ +*.iml +*.ipr +*.iws +/resources/public/cljs diff --git a/examples/todomvc-browserchannel/README.md b/examples/todomvc-browserchannel/README.md new file mode 100644 index 0000000..c48186a --- /dev/null +++ b/examples/todomvc-browserchannel/README.md @@ -0,0 +1,63 @@ +# views.reagent Example - Todo MVC (BrowserChannel) + +This is a modification of the Todo MVC app for Reagent [demonstrated here][1]. +This version of the app has been modified to use a PostgreSQL database +to store the Todos and to provide realtime synchronization of changes +to that data to any number of users currently viewing the app. + +[1]: http://reagent-project.github.io/ + +> **NOTE:** This is a copy of the [other Todo MVC example][2] and is the same +> in every respect, except that this one is using [BrowserChannel][3] instead of +> Sente as the underlying client/server messaging implementation. + +[2]: https://github.com/gered/views.reagent/tree/master/examples/todomvc +[3]: https://github.com/gered/views.reagent/tree/master/views.reagent.browserchannel + +## Running + +### A quick note on the dependencies used + +Since views.reagent and the Views library it depends on are all +currently in somewhat of an experimental / pre-beta state right now, +you will need to first clone the following repositories and manually +install the libraries via `lein install`: + +* [views](https://github.com/gered/views) +* [views.sql](https://github.com/gered/views.sql) +* [views.reagent](https://github.com/gered/views.reagent) + +As well, you can install [views.honeysql](https://github.com/gered/views.honeysql) +if you want to try out using HoneySQL instead of SQL with views. But +this example app does not use it so it's not required. + +### Creating the Database + +This example app uses a PostgreSQL database. The SQL script to create +it is in `create_db.sql`. You can easily pipe it into `psql` at a +command line to create it quickly, for example: + + $ psql < create_db.sql + +(Of course, add any username/host parameters you might need) + +### Starting It Up + +To build everything and run in one step: + + $ lein rundemo + +Then open up a web browser or two and head to http://localhost:8080/ +to see the web app in action. + +If you want to run this application in a REPL, just be sure to build +the ClojureScript: + + $ lein cljsbuild once + +And then in the REPL you can just run: + + (-main) + +to start the web app (you should be put in the correct namespace +immediately). diff --git a/examples/todomvc-browserchannel/create_db.sql b/examples/todomvc-browserchannel/create_db.sql new file mode 100644 index 0000000..ec23eef --- /dev/null +++ b/examples/todomvc-browserchannel/create_db.sql @@ -0,0 +1,21 @@ +-- For PostgreSQL +-- run with psql. e.g. 'psql < create_db.sql' + +CREATE ROLE todomvc LOGIN PASSWORD 's3cr3t'; +CREATE DATABASE todomvc OWNER todomvc; + +-- assumes you're piping this script into psql ... +\c todomvc; + +CREATE TABLE todos +( + id SERIAL PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + done BOOLEAN DEFAULT FALSE NOT NULL +); +ALTER TABLE todos OWNER TO todomvc; + +INSERT INTO todos (title, done) VALUES ('Rename Cloact to Reagent', TRUE); +INSERT INTO todos (title, done) VALUES ('Add undo demo', TRUE); +INSERT INTO todos (title, done) VALUES ('Make all rendering async', TRUE); +INSERT INTO todos (title, done) VALUES ('Allow any arguments to component functions', TRUE); diff --git a/examples/todomvc-browserchannel/project.clj b/examples/todomvc-browserchannel/project.clj new file mode 100644 index 0000000..2fad98e --- /dev/null +++ b/examples/todomvc-browserchannel/project.clj @@ -0,0 +1,56 @@ +(defproject todomvc-browserchannel "0.1.0-SNAPSHOT" + :dependencies [[org.clojure/clojure "1.8.0"] + [org.clojure/clojurescript "1.8.51"] + [ring "1.4.0"] + [ring/ring-defaults "0.2.0" :exclusions [javax.servlet/servlet-api]] + [compojure "1.4.0"] + [org.immutant/web "2.1.4"] + + [org.clojure/java.jdbc "0.6.1"] + [org.postgresql/postgresql "9.4.1208.jre7"] + [gered/clj-browserchannel "0.3.2"] + [gered/clj-browserchannel-immutant-adapter "0.0.3"] + [gered/views "1.5-SNAPSHOT"] + [gered/views.sql "0.1.0-SNAPSHOT"] + [gered/views.reagent "0.2.0-SNAPSHOT"] + [gered/views.reagent.browserchannel "0.1.0-SNAPSHOT"] + + [hiccup "1.0.5"] + [reagent "0.6.0-alpha2"] + [cljs-ajax "0.5.4"] + + [environ "1.0.3"]] + + :plugins [[lein-cljsbuild "1.1.3"] + [lein-environ "1.0.3"]] + + :main todomvc.server + + :clean-targets ^{:protect false} [:target-path + [:cljsbuild :builds :main :compiler :output-dir] + [:cljsbuild :builds :main :compiler :output-to]] + :cljsbuild {:builds {:main + {:source-paths ["src"] + :compiler {:main todomvc.client + :output-to "resources/public/cljs/app.js" + :output-dir "resources/public/cljs/target" + :asset-path "cljs/target" + :source-map true + :optimizations :none + :pretty-print true}}}} + + :profiles {:dev {:env {:dev "true"}} + + :uberjar {:env {} + :aot :all + :hooks [leiningen.cljsbuild] + :cljsbuild {:jar true + :builds {:main + {:compiler ^:replace {:output-to "resources/public/cljs/app.js" + :optimizations :advanced + :pretty-print false}}}}}} + + :aliases {"rundemo" ["do" ["clean"] ["cljsbuild" "once"] ["run"]] + "uberjar" ["do" ["clean"] ["uberjar"]]} + + ) diff --git a/examples/todomvc-browserchannel/resources/public/todos.css b/examples/todomvc-browserchannel/resources/public/todos.css new file mode 100644 index 0000000..9095474 --- /dev/null +++ b/examples/todomvc-browserchannel/resources/public/todos.css @@ -0,0 +1,558 @@ +@charset "utf-8"; +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + color: inherit; + -webkit-appearance: none; + -ms-appearance: none; + -o-appearance: none; + appearance: none; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #eaeaea; + /* background: #eaeaea url('bg.png'); */ + color: #4d4d4d; + width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + -ms-font-smoothing: antialiased; + -o-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +button, +input[type="checkbox"] { + outline: none; +} + +#todoapp { + background: #fff; + background: rgba(255, 255, 255, 0.9); + margin: 130px 0 40px 0; + border: 1px solid #ccc; + position: relative; + border-top-left-radius: 2px; + border-top-right-radius: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.15); +} + +#todoapp:before { + content: ''; + border-left: 1px solid #f5d6d6; + border-right: 1px solid #f5d6d6; + width: 2px; + position: absolute; + top: 0; + left: 40px; + height: 100%; +} + +#todoapp input::-webkit-input-placeholder { + font-style: italic; +} + +#todoapp input::-moz-placeholder { + font-style: italic; + color: #a9a9a9; +} + +#todoapp h1 { + position: absolute; + top: -120px; + width: 100%; + font-size: 70px; + font-weight: bold; + text-align: center; + color: #b3b3b3; + color: rgba(255, 255, 255, 0.3); + text-shadow: -1px -1px rgba(0, 0, 0, 0.2); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + -ms-text-rendering: optimizeLegibility; + -o-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +#header { + padding-top: 15px; + border-radius: inherit; +} + +#header:before { + content: ''; + position: absolute; + top: 0; + right: 0; + left: 0; + height: 15px; + z-index: 2; + border-bottom: 1px solid #6c615c; + background: #8d7d77; + background: -webkit-gradient(linear, left top, left bottom, from(rgba(132, 110, 100, 0.8)),to(rgba(101, 84, 76, 0.8))); + background: -webkit-linear-gradient(top, rgba(132, 110, 100, 0.8), rgba(101, 84, 76, 0.8)); + background: linear-gradient(top, rgba(132, 110, 100, 0.8), rgba(101, 84, 76, 0.8)); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#9d8b83', EndColorStr='#847670'); + border-top-left-radius: 1px; + border-top-right-radius: 1px; +} + +#new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + line-height: 1.4em; + border: 0; + outline: none; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + -o-box-sizing: border-box; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + -ms-font-smoothing: antialiased; + -o-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +#new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.02); + z-index: 2; + box-shadow: none; +} + +#main { + position: relative; + z-index: 2; + border-top: 1px dotted #adadad; +} + +label[for='toggle-all'] { + display: none; +} + +#toggle-all { + position: absolute; + top: -42px; + left: -4px; + width: 40px; + text-align: center; + /* Mobile Safari */ + border: none; +} + +#toggle-all:before { + content: '»'; + font-size: 28px; + color: #d9d9d9; + padding: 0 25px 7px; +} + +#toggle-all:checked:before { + color: #737373; +} + +#todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +#todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px dotted #ccc; +} + +#todo-list li:last-child { + border-bottom: none; +} + +#todo-list li.editing { + border-bottom: none; + padding: 0; +} + +#todo-list li.editing .edit { + display: block; + width: 506px; + padding: 13px 17px 12px 17px; + margin: 0 0 0 43px; +} + +#todo-list li.editing .view { + display: none; +} + +#todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + /* Mobile Safari */ + border: none; + -webkit-appearance: none; + -ms-appearance: none; + -o-appearance: none; + appearance: none; +} + +#todo-list li .toggle:after { + content: '✔'; + /* 40 + a couple of pixels visual adjustment */ + line-height: 43px; + font-size: 20px; + color: #d9d9d9; + text-shadow: 0 -1px 0 #bfbfbf; +} + +#todo-list li .toggle:checked:after { + color: #85ada7; + text-shadow: 0 1px 0 #669991; + bottom: 1px; + position: relative; +} + +#todo-list li label { + white-space: pre; + word-break: break-word; + padding: 15px 60px 15px 15px; + margin-left: 45px; + display: block; + line-height: 1.2; + -webkit-transition: color 0.4s; + transition: color 0.4s; +} + +#todo-list li.completed label { + color: #a9a9a9; + text-decoration: line-through; +} + +#todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 22px; + color: #a88a8a; + -webkit-transition: all 0.2s; + transition: all 0.2s; +} + +#todo-list li .destroy:hover { + text-shadow: 0 0 1px #000, + 0 0 10px rgba(199, 107, 107, 0.8); + -webkit-transform: scale(1.3); + -ms-transform: scale(1.3); + transform: scale(1.3); +} + +#todo-list li .destroy:after { + content: '✖'; +} + +#todo-list li:hover .destroy { + display: block; +} + +#todo-list li .edit { + display: none; +} + +#todo-list li.editing:last-child { + margin-bottom: -1px; +} + +#footer { + color: #777; + padding: 0 15px; + position: absolute; + right: 0; + bottom: -31px; + left: 0; + height: 20px; + z-index: 1; + text-align: center; +} + +#footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 31px; + left: 0; + height: 50px; + z-index: -1; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), + 0 6px 0 -3px rgba(255, 255, 255, 0.8), + 0 7px 1px -3px rgba(0, 0, 0, 0.3), + 0 43px 0 -6px rgba(255, 255, 255, 0.8), + 0 44px 2px -6px rgba(0, 0, 0, 0.2); +} + +#todo-count { + float: left; + text-align: left; +} + +#filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +#filters li { + display: inline; +} + +#filters li a { + color: #83756f; + margin: 2px; + text-decoration: none; +} + +#filters li a.selected { + font-weight: bold; +} + +#clear-completed { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + background: rgba(0, 0, 0, 0.1); + font-size: 11px; + padding: 0 10px; + border-radius: 3px; + box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.2); +} + +#clear-completed:hover { + background: rgba(0, 0, 0, 0.15); + box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.3); +} + +#info { + margin: 65px auto 0; + color: #a6a6a6; + font-size: 12px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); + text-align: center; +} + +#info a { + color: inherit; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox and Opera +*/ + +@media screen and (-webkit-min-device-pixel-ratio:0) { + #toggle-all, + #todo-list li .toggle { + background: none; + } + + #todo-list li .toggle { + height: 40px; + } + + #toggle-all { + top: -56px; + left: -15px; + width: 65px; + height: 41px; + -webkit-transform: rotate(90deg); + -ms-transform: rotate(90deg); + transform: rotate(90deg); + -webkit-appearance: none; + appearance: none; + } +} + +.hidden { + display: none; +} + +hr { + margin: 20px 0; + border: 0; + border-top: 1px dashed #C5C5C5; + border-bottom: 1px dashed #F7F7F7; +} + +.learn a { + font-weight: normal; + text-decoration: none; + color: #b83f45; +} + +.learn a:hover { + text-decoration: underline; + color: #787e7e; +} + +.learn h3, +.learn h4, +.learn h5 { + margin: 10px 0; + font-weight: 500; + line-height: 1.2; + color: #000; +} + +.learn h3 { + font-size: 24px; +} + +.learn h4 { + font-size: 18px; +} + +.learn h5 { + margin-bottom: 0; + font-size: 14px; +} + +.learn ul { + padding: 0; + margin: 0 0 30px 25px; +} + +.learn li { + line-height: 20px; +} + +.learn p { + font-size: 15px; + font-weight: 300; + line-height: 1.3; + margin-top: 0; + margin-bottom: 0; +} + +.quote { + border: none; + margin: 20px 0 60px 0; +} + +.quote p { + font-style: italic; +} + +.quote p:before { + content: '“'; + font-size: 50px; + opacity: .15; + position: absolute; + top: -20px; + left: 3px; +} + +.quote p:after { + content: '”'; + font-size: 50px; + opacity: .15; + position: absolute; + bottom: -42px; + right: 3px; +} + +.quote footer { + position: absolute; + bottom: -40px; + right: 0; +} + +.quote footer img { + border-radius: 3px; +} + +.quote footer a { + margin-left: 5px; + vertical-align: middle; +} + +.speech-bubble { + position: relative; + padding: 10px; + background: rgba(0, 0, 0, .04); + border-radius: 5px; +} + +.speech-bubble:after { + content: ''; + position: absolute; + top: 100%; + right: 30px; + border: 13px solid transparent; + border-top-color: rgba(0, 0, 0, .04); +} + +.learn-bar > .learn { + position: absolute; + width: 272px; + top: 8px; + left: -300px; + padding: 10px; + border-radius: 5px; + background-color: rgba(255, 255, 255, .6); + -webkit-transition-property: left; + transition-property: left; + -webkit-transition-duration: 500ms; + transition-duration: 500ms; +} + +@media (min-width: 899px) { + .learn-bar { + width: auto; + margin: 0 0 0 300px; + } + + .learn-bar > .learn { + left: 8px; + } + + .learn-bar #todoapp { + width: 550px; + margin: 130px auto 40px auto; + } +} \ No newline at end of file diff --git a/examples/todomvc-browserchannel/resources/public/todosanim.css b/examples/todomvc-browserchannel/resources/public/todosanim.css new file mode 100644 index 0000000..999d534 --- /dev/null +++ b/examples/todomvc-browserchannel/resources/public/todosanim.css @@ -0,0 +1,18 @@ + +.todoitem-enter { + opacity: 0.1; + transition: opacity .2s ease-in; +} + +.todoitem-enter.todoitem-enter-active { + opacity: 1; +} + +.todoitem-leave { + opacity: 0.8; + transition: opacity 0.2s ease-out; +} + +.todoitem-leave.todoitem-leave-active { + opacity: 0.1; +} \ No newline at end of file diff --git a/examples/todomvc-browserchannel/src/todomvc/client.cljs b/examples/todomvc-browserchannel/src/todomvc/client.cljs new file mode 100644 index 0000000..215ff34 --- /dev/null +++ b/examples/todomvc-browserchannel/src/todomvc/client.cljs @@ -0,0 +1,180 @@ +(ns todomvc.client + (:require + [reagent.core :as r] + [ajax.core :refer [POST default-interceptors to-interceptor]] + [net.thegeez.browserchannel.client :as browserchannel] + [views.reagent.client.component :refer [view-cursor] :refer-macros [defvc]] + [views.reagent.browserchannel.client :as vr])) + +;; Todo MVC - views.reagent example app +;; +;; This is taken from the example code shown on http://reagent-project.github.io/ +;; It has been modified so that instead of using todo data stored client-side in +;; an atom, the data is retrieved from the server. +;; +;; AJAX requests are used to add/edit/delete the todos. The list is refreshed +;; whenever a change is made (by any client currently viewing the app) by a +;; view subscription. See the 'todo-app' component near the bottom-middle of this +;; file for more details about this. + + + +;; AJAX operations + +(defn add-todo [text] (POST "/todos/add" {:format :url :params {:title text}})) +(defn toggle [id] (POST "/todos/toggle" {:format :url :params {:id id}})) +(defn save [id title] (POST "/todos/update" {:format :url :params {:id id :title title}})) +(defn delete [id] (POST "/todos/delete" {:format :url :params {:id id}})) + +(defn complete-all [v] (POST "/todos/mark-all" {:format :url :params {:done? v}})) +(defn clear-done [] (POST "/todos/delete-all-done")) + + + +;; UI Components + +(defn todo-input + [{:keys [title on-save on-stop]}] + (let [val (r/atom title) + stop #(do (reset! val "") + (if on-stop (on-stop))) + save #(let [v (-> @val str clojure.string/trim)] + (if-not (empty? v) (on-save v)) + (stop))] + (fn [props] + [:input (merge props + {:type "text" :value @val :on-blur save + :on-change #(reset! val (-> % .-target .-value)) + :on-key-down #(case (.-which %) + 13 (save) + 27 (stop) + nil)})]))) + +(def todo-edit (with-meta todo-input + {:component-did-mount #(.focus (r/dom-node %))})) + +(defn todo-stats + [{:keys [filt active done]}] + (let [props-for (fn [name] + {:class (if (= name @filt) "selected") + :on-click #(reset! filt name)})] + [:div + [:span#todo-count + [:strong active] " " (case active 1 "item" "items") " left"] + [:ul#filters + [:li [:a (props-for :all) "All"]] + [:li [:a (props-for :active) "Active"]] + [:li [:a (props-for :done) "Completed"]]] + (when (pos? done) + [:button#clear-completed {:on-click clear-done} + "Clear completed " done])])) + +(defn todo-item + [] + (let [editing (r/atom false)] + (fn [{:keys [id done title]}] + [:li {:class (str (if done "completed ") + (if @editing "editing"))} + [:div.view + [:input.toggle {:type "checkbox" :checked done + :on-change #(toggle id)}] + [:label {:on-double-click #(reset! editing true)} title] + [:button.destroy {:on-click #(delete id)}]] + (when @editing + [todo-edit {:class "edit" :title title + :on-save #(save id %) + :on-stop #(reset! editing false)}])]))) + + + +;; Main TODO app component +;; +;; Note that this component is defined using 'defvc' instead of 'defn'. This is a +;; macro provided by views.reagent which is required to be used by any Reagent +;; component that will directly subscribe/unsubscribe to views. It handles all the +;; housekeeping operations that working with views on the client entails. +;; +;; The call to 'view-cursor' is where the rest of the magic happens. This function +;; will: +;; +;; - Send a subscription request to the server for the specified view and parameters +;; if a subscription for the view (and the exact provided parameters) does not +;; already exist. +;; - Returns the most recent data for this view in a Reagent cursor. When the data +;; is changed and the server sends a view refresh, components dereferencing this +;; cursor will be rerendered, just like any other Reagent atom/cursor. +;; - If the values of the (optional) parameters passed to view-cursor change, a +;; view resubscription (with the new parameters) will be triggered automatically +;; and the server will send us new view data. +;; +;; NOTE: +;; view-cursor cannot be used in a Reagent component that was created using defn. + +(defvc todo-app + [props] + (let [filt (r/atom :all)] + (fn [] + (let [items (view-cursor :todos) + done (->> @items (filter :done) count) + active (- (count @items) done)] + [:div + [:section#todoapp + [:header#header + [:h1 "todos"] + [todo-input {:id "new-todo" + :placeholder "What needs to be done?" + :on-save add-todo}]] + (when (-> @items count pos?) + [:div + [:section#main + [:input#toggle-all {:type "checkbox" :checked (zero? active) + :on-change #(complete-all (pos? active))}] + [:label {:for "toggle-all"} "Mark all as complete"] + [:ul#todo-list + (for [todo (->> @items + (filter + (case @filt + :active (complement :done) + :done :done + :all identity)) + (sort-by :id))] + ^{:key (:id todo)} [todo-item todo])]] + [:footer#footer + [todo-stats {:active active :done done :filt filt}]]])] + [:footer#info + [:p "Double-click to edit a todo"]]])))) + + + +;; Some unfortunately necessary set up to ensure we send the CSRF token back with +;; AJAX requests + +(defn get-anti-forgery-token + [] + (if-let [hidden-field (.getElementById js/document "__anti-forgery-token")] + (.-value hidden-field))) + +(def csrf-interceptor + (to-interceptor {:name "CSRF Interceptor" + :request #(assoc-in % [:headers "X-CSRF-Token"] (get-anti-forgery-token))})) + +(swap! default-interceptors (partial cons csrf-interceptor)) + + + +;; Page load + +(defn ^:export run + [] + ; Initialize views.reagent + (vr/init!) + + ; Initialize BrowserChannel + ; NOTE: We are passing in an empty event handler map to connect! only because + ; this todo app is not using BrowserChannel for any purpose other then to + ; provide client/server messaging for views.reagent. If we wanted to use it + ; for client/server messaging in our application as well, we could pass in + ; any event handlers we want here and it would not intefere with views.reagent. + (browserchannel/connect! {} {:middleware [vr/middleware]}) + + (r/render-component [todo-app] (.getElementById js/document "app"))) diff --git a/examples/todomvc-browserchannel/src/todomvc/server.clj b/examples/todomvc-browserchannel/src/todomvc/server.clj new file mode 100644 index 0000000..6e3be74 --- /dev/null +++ b/examples/todomvc-browserchannel/src/todomvc/server.clj @@ -0,0 +1,188 @@ +(ns todomvc.server + (:gen-class) + (:require + [compojure.core :refer [routes GET POST]] + [compojure.route :as route] + [ring.middleware.defaults :refer [wrap-defaults site-defaults]] + [ring.util.anti-forgery :refer [anti-forgery-field]] + [ring.util.response :refer [response]] + [net.thegeez.browserchannel.server :refer [wrap-browserchannel]] + [net.thegeez.browserchannel.immutant-async-adapter :refer [wrap-immutant-async-adapter]] + [immutant.web :as immutant] + [hiccup.page :refer [html5 include-css include-js]] + [hiccup.element :refer [javascript-tag]] + [environ.core :refer [env]] + [clojure.java.jdbc :as jdbc] + [views.sql.core :refer [vexec! with-view-transaction]] + [views.sql.view :refer [view]] + [views.reagent.browserchannel.server :as vr])) + +(def dev? (boolean (env :dev))) + +(def db {:classname "org.postgresql.Driver" + :subprotocol "postgresql" + :subname "//localhost/todomvc" + :user "todomvc" + :password "s3cr3t"}) + + + +;; View system atom +;; +;; We just declare it, don't need to fill it with anything. The call below to +;; views.reagent.browserchannel.server/init! will take care of it. + +(defonce view-system (atom {})) + + + +;; View functions. +;; +;; These are functions which accept any number of parameters provided when the view +;; is subscribed to and run whenever a subscriber needs refreshed data for it. +;; +;; A view function's return value requirement depends on what views IView +;; implementation is being used. +;; +;; This example app is using views.sql, so view templates should return a SQL SELECT +;; query in a clojure.java.jdbc "sqlvec" which is a vector where the first string is +;; the actual SQL query and is followed by any number of parameters to be used in +;; the query. + +(defn todos-list + [] + ["SELECT id, title, done FROM todos ORDER BY title"]) + + + +;; Views list. +;; +;; A definition/declaration of the views in the system. Each view is given an id and +;; points to a function that returns the query that will be used to retrieve the view's +;; data. Also other properties can be provided to each view, such as the database connection. +;; +;; The view id and parameters to the view function get used later on to form a +;; "view signature" or "view-sig" when the client subscribes to a view. + +(def views + [(view :todos db #'todos-list)]) + + + +;; SQL operations triggered by AJAX requests. +;; +;; These functions are just your ordinary AJAX request handlers that do the various +;; CRUD operations on the example app's data. The only difference is that instead +;; of using clojure.java.jdbc/execute!, we instead use vexec!. +;; +;; vexec! performs the exact same operation as execute!, except that it also +;; analyzes the SQL query being run and dispatches "hints" to the view system which +;; trigger view refrehses for all subscribers of the views that the hints match. + +(defn add-todo! + [title] + (vexec! view-system db ["INSERT INTO todos (title) VALUES (?)" title]) + (response "ok")) + +(defn delete-todo! + [id] + (vexec! view-system db ["DELETE FROM todos WHERE id = ?" id]) + (response "ok")) + +(defn update-todo! + [id title] + (vexec! view-system db ["UPDATE todos SET title = ? WHERE id = ?" title id]) + (response "ok")) + +(defn toggle-todo! + [id] + ; note that a transaction is obviously not necessary here as we could have used + ; just a single UPDATE query. however, it is being done this way to demonstrate + ; using transactions with vexec!. + (with-view-transaction + view-system + [dt db] + (let [done? (:done (first (jdbc/query dt ["SELECT done FROM todos WHERE id = ?" id])))] + (vexec! view-system dt ["UPDATE todos SET done = ? WHERE id = ?" (not done?) id])) + (response "ok"))) + +(defn mark-all! + [done?] + (vexec! view-system db ["UPDATE todos SET done = ?" done?]) + (response "ok")) + +(defn delete-all-done! + [] + (vexec! view-system db ["DELETE FROM todos WHERE done = true"]) + (response "ok")) + + + +;; main page html + +(defn render-page + [] + (html5 + [:head + [:title "TodoMVC - views.reagent Example"] + (include-css "todos.css" "todosanim.css") + (include-js "cljs/app.js")] + [:body + (anti-forgery-field) + [:div#app [:h1 "This will become todomvc when the ClojureScript is compiled"]] + (javascript-tag "todomvc.client.run();")])) + + + +;; Compojure routes and Ring handler + +(def app-routes + (routes + ; db action routes + (POST "/todos/add" [title] (add-todo! title)) + (POST "/todos/delete" [id] (delete-todo! (Integer/parseInt id))) + (POST "/todos/update" [id title] (update-todo! (Integer/parseInt id) title)) + (POST "/todos/toggle" [id] (toggle-todo! (Integer/parseInt id))) + (POST "/todos/mark-all" [done?] (mark-all! (Boolean/parseBoolean done?))) + (POST "/todos/delete-all-done" [] (delete-all-done!)) + + ; main page + (GET "/" [] (render-page)) + + (route/resources "/") + (route/not-found "not found"))) + +; NOTE: We are passing in an empty event handler map to wrap-browserchannel only +; because this todo app is not using BrowserChannel for any purpose other +; then to provide client/server messaging for views.reagent. If we +; wanted to use it for client/server messaging in our application as well, +; we could pass in any event handlers we want here and it would not intefere +; with views.reagent. + +(def handler + (-> app-routes + (wrap-defaults (assoc-in site-defaults [:security :anti-forgery] (not dev?))) + (wrap-browserchannel {} {:middleware [(vr/->middleware view-system)]}) + (wrap-immutant-async-adapter))) + + + +;; Web server startup & main + +(defn run-server + [] + ; views.reagent.browserchannel.server/init! takes care of initialization of views + ; and views.reagent at the same time. As a result, we do not need to also call + ; views.core/init! anywhere. The same arguments and options you are able to pass to + ; views.core/init! can also be passed in here and they will be forwarded along, as + ; this function is intended to be a drop-in replacement for views.core/init!. + ; + ; if you need to shutdown the views system (e.g. if you're using something like + ; Component or Mount), you can just call views.core/shutdown!. + (vr/init! view-system {:views views}) + + (immutant/run handler {:port 8080})) + +(defn -main + [& args] + (run-server)) diff --git a/examples/todomvc/README.md b/examples/todomvc/README.md index 21f70a5..b085596 100644 --- a/examples/todomvc/README.md +++ b/examples/todomvc/README.md @@ -17,10 +17,10 @@ you will need to first clone the following repositories and manually install the libraries via `lein install`: * [views](https://github.com/gered/views) -* [views-sql](https://github.com/gered/views-sql) +* [views.sql](https://github.com/gered/views.sql) * [views.reagent](https://github.com/gered/views.reagent) -As well, you can install [views-honeysql](https://github.com/gered/views-honeysql) +As well, you can install [views.honeysql](https://github.com/gered/views.honeysql) if you want to try out using HoneySQL instead of SQL with views. But this example app does not use it so it's not required. diff --git a/examples/todomvc/project.clj b/examples/todomvc/project.clj index 39238af..4e08469 100644 --- a/examples/todomvc/project.clj +++ b/examples/todomvc/project.clj @@ -8,12 +8,11 @@ [org.clojure/java.jdbc "0.6.1"] [org.postgresql/postgresql "9.4.1208.jre7"] - [gered/clj-browserchannel "0.3.2"] - [gered/clj-browserchannel-immutant-adapter "0.0.3"] + [com.taoensso/sente "1.8.1"] [gered/views "1.5-SNAPSHOT"] [gered/views.sql "0.1.0-SNAPSHOT"] [gered/views.reagent "0.2.0-SNAPSHOT"] - [gered/views.reagent.browserchannel "0.1.0-SNAPSHOT"] + [gered/views.reagent.sente "0.1.0-SNAPSHOT"] [hiccup "1.0.5"] [reagent "0.6.0-alpha2"] diff --git a/examples/todomvc/src/todomvc/client.cljs b/examples/todomvc/src/todomvc/client.cljs index 3cda04e..c0120bb 100644 --- a/examples/todomvc/src/todomvc/client.cljs +++ b/examples/todomvc/src/todomvc/client.cljs @@ -2,9 +2,9 @@ (:require [reagent.core :as r] [ajax.core :refer [POST default-interceptors to-interceptor]] - [net.thegeez.browserchannel.client :as browserchannel] + [taoensso.sente :as sente] [views.reagent.client.component :refer [view-cursor] :refer-macros [defvc]] - [views.reagent.browserchannel.client :as vr])) + [views.reagent.sente.client :as vr])) ;; Todo MVC - Reagent Implementation ;; @@ -19,6 +19,15 @@ +;; Sente socket +;; +;; This just holds the socket map returned by sente's make-channel-socket! +;; so we can refer to it and pass it around as needed. + +(defonce sente-socket (atom {})) + + + ;; AJAX operations (defn add-todo [text] (POST "/todos/add" {:format :url :params {:title text}})) @@ -162,19 +171,45 @@ +;; Sente event/message handler +;; +;; Note that if you're only using Sente to make use of views.reagent in your app +;; and aren't otherwise using it for any other client/server messaging, you can +;; set :use-default-sente-router? to true in the options passed to +;; views.reagent.sente.client/init! (called in run below). Then you will not +;; need to provide a handler like this to start-chsk-router! as one will be +;; provided automatically. + +(defn sente-event-msg-handler + [{:keys [event id client-id] :as ev}] + (let [[ev-id ev-data] event] + (cond + (and (= :chsk/state ev-id) + (:open? ev-data)) + (vr/on-open! @sente-socket ev) + + (= :chsk/recv id) + (when-not (vr/on-receive! @sente-socket ev) + ; on-receive! returns true if the event was a views.reagent event and it + ; handled it. + ; + ; you could put your code to handle your app's own events here + )))) + + + ;; Page load (defn ^:export run [] - ; Configure views.reagent and then BrowserChannel. - (vr/init!) + ; Sente setup. create the socket, storing it in an atom and set up a event + ; handler using sente's own message router functionality. + (reset! sente-socket (sente/make-channel-socket! "/chsk" {})) - ; NOTE: We are passing in an empty map for the BrowserChannel event handlers only - ; because this todo app is not using BrowserChannel for any purpose other - ; then to provide client/server messaging for views.reagent. If we - ; wanted to use it for client/server messaging in our application as well, - ; we could pass in any event handlers we want here and it would not intefere - ; with views.reagent. - (browserchannel/connect! {} {:middleware [vr/middleware]}) + ; set up a handler for sente events + (sente/start-chsk-router! (:ch-recv @sente-socket) sente-event-msg-handler) + + ; Configure views.reagent for use with Sente. + (vr/init! @sente-socket {}) (r/render-component [todo-app] (.getElementById js/document "app"))) diff --git a/examples/todomvc/src/todomvc/server.clj b/examples/todomvc/src/todomvc/server.clj index 11b301a..d966cab 100644 --- a/examples/todomvc/src/todomvc/server.clj +++ b/examples/todomvc/src/todomvc/server.clj @@ -6,8 +6,8 @@ [ring.middleware.defaults :refer [wrap-defaults site-defaults]] [ring.util.anti-forgery :refer [anti-forgery-field]] [ring.util.response :refer [response]] - [net.thegeez.browserchannel.server :refer [wrap-browserchannel]] - [net.thegeez.browserchannel.immutant-async-adapter :refer [wrap-immutant-async-adapter]] + [taoensso.sente :as sente] + [taoensso.sente.server-adapters.immutant :refer [sente-web-server-adapter]] [immutant.web :as immutant] [hiccup.page :refer [html5 include-css include-js]] [hiccup.element :refer [javascript-tag]] @@ -15,7 +15,7 @@ [clojure.java.jdbc :as jdbc] [views.sql.core :refer [vexec! with-view-transaction]] [views.sql.view :refer [view]] - [views.reagent.browserchannel.server :as vr])) + [views.reagent.sente.server :as vr])) (def dev? (boolean (env :dev))) @@ -27,10 +27,19 @@ +;; Sente socket +;; +;; This just holds the socket map returned by sente's make-channel-socket! +;; so we can refer to it and pass it around as needed. + +(defonce sente-socket (atom {})) + + + ;; View system atom ;; ;; We just declare it, don't need to fill it with anything. The call below to -;; views.reagent.browserchannel.server/init! will take care of it. +;; views.reagent.sente.server/init! will take care of initializing it. (defonce view-system (atom {})) @@ -44,7 +53,7 @@ ;; A view function's return value requirement depends on what views IView ;; implementation is being used. ;; -;; This example app is using views-sql, so view templates should return a SQL SELECT +;; This example app is using views.sql, so view templates should return a SQL SELECT ;; query in a clojure.java.jdbc "sqlvec" which is a vector where the first string is ;; the actual SQL query and is followed by any number of parameters to be used in ;; the query. @@ -146,6 +155,10 @@ (POST "/todos/mark-all" [done?] (mark-all! (Boolean/parseBoolean done?))) (POST "/todos/delete-all-done" [] (delete-all-done!)) + ; sente routes + (GET "/chsk" request ((:ajax-get-or-ws-handshake-fn @sente-socket) request)) + (POST "/chsk" request ((:ajax-post-fn @sente-socket) request)) + ; main page (GET "/" [] (render-page)) @@ -154,15 +167,29 @@ (def handler (-> app-routes - (wrap-defaults (assoc-in site-defaults [:security :anti-forgery] true #_(not dev?))) - ; NOTE: We are passing in an empty map for the BrowserChannel event handlers only - ; because this todo app is not using BrowserChannel for any purpose other - ; then to provide client/server messaging for views.reagent. If we - ; wanted to use it for client/server messaging in our application as well, - ; we could pass in any event handlers we want here and it would not intefere - ; with views.reagent. - (wrap-browserchannel {} {:middleware [(vr/->middleware view-system)]}) - (wrap-immutant-async-adapter))) + (wrap-defaults (assoc-in site-defaults [:security :anti-forgery] (not dev?))))) + + + +;; Sente event/message handler +;; +;; Note that if you're only using Sente to make use of views.reagent in your app +;; and aren't otherwise using it for any other client/server messaging, you can +;; set :use-default-sente-router? to true in the options passed to +;; views.reagent.sente.server/init! (called in run-server below). Then you will +;; not need to provide a handler like this to start-chsk-router! as one will be +;; provided automatically. + +(defn sente-event-msg-handler + [{:keys [event id uid client-id] :as ev}] + (if (= id :chsk/uidport-close) + (vr/on-close! view-system ev) + (when-not (vr/on-receive! view-system ev) + ; on-receive! returns true if the event was a views.reagent event and it + ; handled it. + ; + ; you could put your code to handle your app's own events here + ))) @@ -170,14 +197,30 @@ (defn run-server [] - ; init! takes care of initialization of views and views.reagent at the same - ; time. As a result, we do not need to also call views.core/init! anywhere. The - ; same arguments and options you are able to pass to views.core/init! can also be - ; passed in here and they will be forwarded along, as this function is intended to be - ; a drop-in replacement for views.core/init!. - ; if you need to shutdown the views system (e.g. if you're using something like + ; sente setup. create the socket, storing it in an atom and set up a event + ; handler using sente's own message router functionality. + ; in this example app we are setting up sente user-id's to just be the + ; client-id, but you can obviously set this up however you wish. + (reset! sente-socket + (sente/make-channel-socket! + sente-web-server-adapter + {:user-id-fn (fn [request] (get-in request [:params :client-id]))})) + + ; set up a handler for sente events + (sente/start-chsk-router! (:ch-recv @sente-socket) sente-event-msg-handler) + + ; views.reagent.sente.server/init! takes care of two things for us: + ; + ; 1. initialization of the base views system + ; 2. customization of views system config for use with sente + ; + ; As a result, we do not also need to call views.core/init! anywhere, as + ; this performs the exact same function and also accepts the same arguments + ; and options -- see the docs for views.core/init! for more info. + ; + ; If you need to shutdown the views system (e.g. if you're using something like ; Component or Mount), you can just call views.core/shutdown!. - (vr/init! view-system {:views views}) + (vr/init! view-system @sente-socket {:views views}) (immutant/run handler {:port 8080}))