web.lisp 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. #|
  2. This file is a part of timeliner project.
  3. Copyright (c) 2014 Innokenty Enikeev (me@enikesha.net)
  4. |#
  5. (in-package :cl-user)
  6. (restas:define-module #:timeliner.web
  7. (:use :cl :parenscript #:timeliner.utils))
  8. (in-package #:timeliner.web)
  9. (restas::register-pkgmodule-traits 'timeliner.web
  10. :render-method (lambda () (make-instance 'timeliner.web::renderer)))
  11. ;; Cron
  12. (defvar *run-cron* t "Controls if starting a web should run cron tasks")
  13. (defvar *crons* (list
  14. (list #'timeliner.locations:on-cron '(:minute 0 :hour *))
  15. (list #'timeliner.twitter:on-cron '(:minute 1 :hour *))
  16. (list #'timeliner.foursquare:on-cron '(:minute 5 :hour *))
  17. (list #'timeliner.financisto:on-cron '(:minute 0 :hour 7)))
  18. "List of cron functions with their schedules")
  19. (defvar *cron-timers* nil)
  20. (defmethod restas:initialize-module-instance :before ((module (eql #.*package*)) context)
  21. (restas:with-context context
  22. (alexandria:when-let (file (probe-file "config.lisp"))
  23. (load file))
  24. (cl-mongo:mongo :host "10.8.0.6")
  25. (cl-mongo:db.use "timeline")
  26. (when *run-cron*
  27. (restas:context-add-variable
  28. context '*cron-timers*
  29. (loop for (function schedule) in *crons*
  30. collect
  31. (clon:schedule-function function
  32. (clon:make-scheduler
  33. (apply #'clon:make-typed-cron-schedule schedule)
  34. :allow-now-p t)
  35. :thread t))))))
  36. (defmethod restas:finalize-module-instance :after ((module (eql #.*package*)) context)
  37. (let ((timers (restas:context-symbol-value context '*cron-timers*)))
  38. (mapcar #'trivial-timers::unschedule-timer timers)))
  39. ;; Static file path
  40. (defparameter *resources*
  41. (merge-pathnames "resources/"
  42. (asdf:component-pathname (asdf:find-system '#:timeliner))))
  43. ;; Rendering
  44. (defgeneric finalize-page (data)
  45. (:documentation "Final rendering step"))
  46. (defgeneric render-route-data (route data)
  47. (:documentation "Process route-specific data"))
  48. (setf (who:html-mode) :html5)
  49. (defclass renderer () ())
  50. (defun scripts (&rest scripts)
  51. (who:with-html-output-to-string (out)
  52. (loop for s in scripts
  53. do (who:htm (:script :src (restas:genurl 'static/js :file s))))))
  54. (defun css (&rest files)
  55. (who:with-html-output-to-string (out)
  56. (loop for f in files
  57. do (who:htm (:link :href (restas:genurl 'static/css :file f) :rel "stylesheet")))))
  58. (defmethod finalize-page ((data list))
  59. (who:with-html-output-to-string (out nil :prologue t)
  60. (:html
  61. (:head
  62. (:meta :charset "utf-8")
  63. (:meta :http-equiv "X-UA-Compatible" :content "IE=edge")
  64. (:meta :name "viewport" :content "width=device-width, initial-scale=1")
  65. (:title (who:str (getf data :title)))
  66. (who:str (apply #'css (list* "bootstrap.min.css"
  67. "bootstrap-theme.min.css"
  68. "timeliner.css"
  69. (getf data :styles)))))
  70. (:body
  71. (who:str (getf data :menu))
  72. (:div :class "container"
  73. (who:str (getf data :content)))
  74. (:div :id "modal" :class "modal fade" :tabindex "-1" :role "dialog" :aria-hidden "true")
  75. (who:str (apply #'scripts (list* "jquery.min.js"
  76. "bootstrap.min.js"
  77. (getf data :scripts))))
  78. (who:str (getf data :inline-scripts))))))
  79. (defun menu (route)
  80. (who:with-html-output-to-string (out)
  81. (:div :class "navbar navbar-inverse navbar-fixed-top"
  82. :role "navigation"
  83. (:div :class "container"
  84. (:div :class "navbar-header"
  85. (:button :type "button" :class "navbar-toggle"
  86. :data-toggle "collapse"
  87. :data-target ".navbar-collapse"
  88. (:span :class "sr-only" "Toggle navigation")
  89. (:span :class "icon-bar")
  90. (:span :class "icon-bar")
  91. (:span :class "icon-bar"))
  92. (:a :class "navbar-brand"
  93. :href (restas:genurl 'index)
  94. "Timeliner"))
  95. (:div :class "collapse navbar-collapse"
  96. (:ul :class "nav navbar-nav"
  97. (:li :class (case route ('index "active"))
  98. (:a :href (restas:genurl 'index) "Home"))
  99. (:li :class (case route ('about "active"))
  100. (:a :href (restas:genurl 'about) "About"))))))))
  101. (defmethod render-route-data (route data)
  102. (list*
  103. :menu (menu route)
  104. :title (getf data :title "Timeliner")
  105. data))
  106. (defmethod restas:render-object ((designer renderer) (object list))
  107. (let ((full-data (render-route-data (restas:route-symbol restas:*route*)
  108. object)))
  109. (finalize-page full-data)))
  110. (restas:define-route index ("")
  111. (list))
  112. (restas:define-route day ("day/:date")
  113. (list :date date))
  114. (defmethod tag/script (body)
  115. (who:with-html-output-to-string (out)
  116. (:script :type "text/javascript" (who:str body))))
  117. (defmethod render-route-data ((route (eql 'index)) data)
  118. (call-next-method route
  119. (list :title "Main - Timeliner"
  120. :styles '("leaflet.css"
  121. "bootstrap-datepicker.css")
  122. :scripts '("moment.min.js"
  123. "bootstrap-datepicker.js"
  124. "bootstrap-datepaginator.min.js"
  125. "leaflet-src.js"
  126. "underscore.js"
  127. "backbone.js"
  128. "timeliner.js")
  129. :content
  130. (who:with-html-output-to-string (out)
  131. (:div :class "row"
  132. (:div :class "col-sm-12"
  133. (:div :id "paginator")))
  134. (:div :class "row"
  135. (:div :class "col-sm-6"
  136. (:div :id "events" :class "list-group"
  137. (:a :class "list-group-item"
  138. "Loading")))
  139. (:div :class "col-sm-6"
  140. (:div :id "map"))))
  141. :inline-scripts
  142. (tag/script
  143. (ps ($ (lambda () (chain *timeliner (start (lisp (getf data :date)))))))))))
  144. (defmethod render-route-data ((route (eql 'day)) data)
  145. (render-route-data 'index data))
  146. (restas:define-route about ("about/")
  147. (list :title "About Timeliner"
  148. :content
  149. (who:with-html-output-to-string (out)
  150. (:h1 "About"))))
  151. (restas:define-route api/events ("api/events/"
  152. :method :GET
  153. :content-type "application/json")
  154. (let* ((start (or (ignore-errors (ms->ts (parse-integer (hunchentoot:get-parameter "start"))))
  155. (local-time:today)))
  156. (end (or (ignore-errors (ms->ts (parse-integer (hunchentoot:get-parameter "end"))))
  157. (local-time:timestamp+ start 1 :day))))
  158. (with-output-to-string (stream)
  159. (yason:encode
  160. (cl-mongo:docs
  161. (cl-mongo:db.sort "events" ($between "ts" start end) :field "ts" :limit 2000))
  162. stream))))
  163. (restas:define-route api/events/delete ("api/events/:id"
  164. :method :DELETE
  165. :content-type "application/json")
  166. (cl-mongo:db.delete "events" (cl-mongo:$ "_id" (cl-mongo::make-bson-oid
  167. :oid (crypto:hex-string-to-byte-array id))))
  168. "OK")
  169. (defparameter +timeliner.css+
  170. (css-lite:css
  171. (("body") (:padding-top "70px" :padding-bottom "30px"))
  172. (("#map") (:height "600px" :border-radius "6px"))
  173. (("#events") (:height "600px" :overflow-y "scroll"))
  174. (("#paginator") (:margin-bottom "10px"))))
  175. (restas:define-route static/css ("css/:file" :content-type "text/css")
  176. (cond
  177. ((equal file "timeliner.css") +timeliner.css+)
  178. (t (merge-pathnames (format nil "css/~A" file) *resources*))))
  179. ;;; parenscript macros
  180. (defpsmacro ! (&rest method-calls)
  181. `(chain ,@method-calls))
  182. (defparameter +timeliner.js+
  183. (ps
  184. ;; Base app structure
  185. (setf (@ window *timeliner)
  186. (create
  187. *models (create)
  188. *collections (create)
  189. *views (create)
  190. dispatcher (! _ (clone (@ *backbone *events)))
  191. :start (lambda (date)
  192. (var router (new ((@ *timeliner *router))))
  193. (var events (new ((@ *timeliner *collections *events))))
  194. (setf (@ *timeliner events) events)
  195. (setf (@ events url) (lisp (restas:genurl 'api/events)))
  196. (new ((@ *timeliner *views *paginator)
  197. (create :el "#paginator"
  198. :date date)))
  199. (new ((@ *timeliner *views *events)
  200. (create
  201. :el "#events"
  202. :collection events)))
  203. (new ((@ *timeliner *views *map)
  204. (create :el "#map"
  205. :collection events)))
  206. (! router (on "route:home"
  207. (lambda ()
  208. (var today (! (moment)
  209. (format "YYYY-MM-DD")))
  210. (! router (navigate
  211. (+ "day/" today)
  212. (create :trigger t :replace t))))))
  213. (! router (on "route:day"
  214. (lambda (day)
  215. (! events (fetch
  216. (create
  217. :reset t
  218. :data (create
  219. :start (!
  220. (moment day "YYYY-MM-DD")
  221. (value-of)))))))))
  222. (! *backbone history (start (create push-state t)))
  223. (setf (@ *timeliner router) router))))
  224. ;; * Router
  225. (setf (@ *timeliner *router)
  226. (! *backbone *router
  227. (extend (create :routes (create
  228. "" "home"
  229. "day/:day" "day")))))
  230. ;; * Models
  231. (setf (@ *timeliner *models *state)
  232. (! *backbone *model (extend (create
  233. :defaults (create
  234. :day nil)))))
  235. (setf (@ *timeliner *models *event)
  236. (! *backbone *model (extend (create
  237. id-attribute "_id"
  238. :defaults (create
  239. :ts nil
  240. :type nil
  241. :title nil
  242. :loc nil)))))
  243. ;; * Collections
  244. (setf (@ *timeliner *collections *events)
  245. (! *backbone *collection (extend (create
  246. :model (@ *timeliner *models *event)))))
  247. ;; * Views
  248. ;; ** Dialog box
  249. (setf (@ *timeliner *views *dialog)
  250. (! *backbone *view
  251. (extend
  252. (create
  253. class-name "modal-dialog modal-sm"
  254. buttons (list
  255. (create :text "Cancel" :class "btn-default" :event "cancel"
  256. :attrs (create :data-dismiss "modal"))
  257. (create :text "Ok" :class "btn-primary" :event "ok"))
  258. initialize (lambda (opts)
  259. (! _ (extend this (! _ (pick (or opts (create))
  260. "title" "body" "buttons")))))
  261. render-button (lambda (btn)
  262. (var $btn ($ (who-ps-html
  263. (:button :class "btn"
  264. (@ btn text)))))
  265. (var self this)
  266. (when (@ btn class)
  267. (! $btn (add-class (@ btn class))))
  268. (when (@ btn attrs)
  269. (! $btn (attr (@ btn attrs))))
  270. (! $btn (on "click"
  271. (lambda (e)
  272. (when (@ btn event)
  273. (! self (trigger
  274. (+ "button:"
  275. (@ btn event)))))
  276. (! ($ "#modal") (modal "hide")))))
  277. $btn)
  278. render (lambda ()
  279. (! this $el (html
  280. (who-ps-html
  281. (:div :class "modal-content"
  282. (:div :class "modal-header"
  283. (:h4 :class "modal-title" (@ this title)))
  284. (:div :class "modal-body"
  285. (@ this body))
  286. (:div :class "modal-footer")))))
  287. (var self this)
  288. (! this $el (find ".modal-footer")
  289. (append
  290. (loop for b in (@ self buttons)
  291. collect (! self (render-button b)))))
  292. this)
  293. show (lambda ()
  294. (! ($ "#modal") (html (! this (render) el)) (modal))
  295. this)))))
  296. ;; ** DeleteEventDialog
  297. (setf (@ *timeliner *views *delete-event)
  298. (! *timeliner *views *dialog
  299. (extend
  300. (create
  301. title "Delete event?"
  302. body "Are you sure you want to delete event?"
  303. buttons (list
  304. (create :text "Cancel" :class "btn-default" :event "cancel"
  305. :attrs (create :data-dismiss "modal"))
  306. (create :text "Delete" :class "btn-danger" :event "delete"))))))
  307. ;; ** Event
  308. (setf (@ *timeliner *views *event)
  309. (! *backbone *view
  310. (extend
  311. (create
  312. tag-name "a"
  313. class-name "list-group-item"
  314. :events (create
  315. "click" "select"
  316. "dblclick" "delete")
  317. initialize (lambda ()
  318. (! this (listen-to (@ this model) "change" (@ this render)))
  319. (! this (listen-to (@ this model) "destroy" (@ this remove))))
  320. icon-class (lambda ()
  321. (case (! this model (get :type))
  322. (:place "glyphicon glyphicon-map-marker")
  323. (:finance "glyphicon glyphicon-usd")
  324. (:checkin "glyphicon glyphicon-ok-circle")
  325. (:twitter "glyphicon glyphicon-pencil")))
  326. render (lambda ()
  327. (! this $el (attr (create
  328. :href "#"
  329. :data-type (! this model (get :type)))))
  330. (! this $el (html
  331. (who-ps-html
  332. (:span :class "label label-info pull-right"
  333. (:span :class "glyphicon glyphicon-time")
  334. " "
  335. (! (moment
  336. (! this model (get :ts)))
  337. (format "HH:mm")))
  338. (:span :class (! this (icon-class)))
  339. " "
  340. (! this model (get :title)))))
  341. this)
  342. select (lambda (e)
  343. (! e (prevent-default))
  344. (! this $el
  345. (siblings) (remove-class "active") (end)
  346. (add-class "active"))
  347. (! *timeliner dispatcher
  348. (trigger "event:selected" (@ this model))))
  349. delete (lambda (e)
  350. (! this (listen-to-once
  351. (! (new (@ *timeliner *views *delete-event))
  352. (show))
  353. "button:delete"
  354. (lambda ()
  355. (! this model (destroy))))))))))
  356. ;; ** Events
  357. (setf
  358. (@ *timeliner *views *events)
  359. (! *backbone *view
  360. (extend
  361. (create
  362. initialize (lambda ()
  363. (! this (listen-to (@ this collection) "reset" (@ this render))))
  364. render-one (lambda (event)
  365. (var item-view (new ((@ *timeliner *views *event)
  366. (create :model event))))
  367. (! this $el (append (@ (! item-view (render)) $el))))
  368. render (lambda ()
  369. (! this $el (empty))
  370. (if (> (@ this collection length) 0)
  371. (! this collection (each (@ this render-one) this))
  372. (! this $el (append (who-ps-html (:li :class "list-group-item"
  373. "No data")))))
  374. this)))))
  375. ;; ** Paginator
  376. (setf (@ *timeliner *views *paginator)
  377. (! *backbone *view
  378. (extend
  379. (create
  380. :events (create selected-date-changed "changed")
  381. :initialize (lambda (opts)
  382. (! this $el (datepaginator))
  383. (when (@ opts date)
  384. (! this $el (datepaginator "setSelectedDate"
  385. (list (@ opts date)
  386. "YYYY-MM-DD")))))
  387. :changed (lambda (e date)
  388. (! *timeliner router
  389. (navigate
  390. (+ "day/" (! date (format "YYYY-MM-DD")))
  391. (create :trigger t))))))))
  392. ;; ** Map
  393. (setf (@ *timeliner *views *map)
  394. (!
  395. *backbone *view
  396. (extend
  397. (create
  398. initialize (lambda ()
  399. (var tile-layer
  400. (! *L (tile-layer "http://{s}.tiles.mapbox.com/v3/{id}/{z}/{x}/{y}.png"
  401. (create :id "enikesha.icd0bj4k" max-Zoom 18))))
  402. (setf (@ this events-layer) (! *L (feature-group)))
  403. (setf (@ this path-layer) (! *L (polyline (list)
  404. (create
  405. :weight 4
  406. :color "#f30"))))
  407. (setf (@ this map)
  408. (! *L (map (@ this el)
  409. (create
  410. :center '(59.94 30.33)
  411. :zoom 10
  412. :layers (list tile-layer
  413. (@ this path-layer)
  414. (@ this events-layer))))))
  415. (! this (listen-to (@ this collection) "reset"
  416. (@ this render)))
  417. (! this (listen-to (@ this collection) "change"
  418. (@ this render)))
  419. (! this (listen-to (@ this collection) "add"
  420. (@ this render)))
  421. (! this (listen-to (@ this collection) "remove"
  422. (@ this render)))
  423. (! this (listen-to (@ *timeliner dispatcher) "event:selected"
  424. (@ this highlight))))
  425. render (lambda ()
  426. (! this events-layer (clear-layers))
  427. (! this path-layer (splice-lat-lngs 0))
  428. (! this collection
  429. (each (lambda (e)
  430. (var loc (! e (get "loc")))
  431. (when loc
  432. (var marker (! *L (circle-marker
  433. (list (@ loc coordinates 1)
  434. (@ loc coordinates 0))
  435. (create
  436. :radius 4
  437. :weight 1
  438. :color "#000"
  439. fill-color "#F00"
  440. fill-opacity 1))))
  441. (setf (@ e "_layer") marker)
  442. (! this path-layer (add-lat-lng
  443. (! marker (get-lat-lng))))
  444. (! this events-layer (add-layer marker))))
  445. this))
  446. (when (@ this collection length)
  447. (set-timeout
  448. (! _ (bind (lambda ()
  449. (! this map (fit-bounds
  450. (! this events-layer (get-bounds))
  451. (create :padding '(50 50)))))
  452. this))
  453. 200))
  454. this)
  455. highlight (lambda (event)
  456. (var layer (@ event "_layer"))
  457. (! this events-layer
  458. (set-style (create fill-color "#F00")))
  459. (if layer
  460. (progn
  461. (! layer (set-style (create fill-color "#03F")))
  462. (when (and (not (@ *L *browser ie))
  463. (not (@ *L *browser opera)))
  464. (! layer (bring-to-front)))
  465. (! this map (set-view (! layer (get-lat-lng))
  466. 16)))
  467. (! this map (fit-bounds
  468. (! this events-layer (get-bounds))
  469. (create :padding '(50 50))))))))))))
  470. (restas:define-route static/js ("js/:file" :content-type "application/x-javascript")
  471. (cond
  472. ((equal file "timeliner.js") +timeliner.js+)
  473. (t (merge-pathnames (format nil "js/~A" file) *resources*))))
  474. (restas:define-route static/fonts ("fonts/:file")
  475. (merge-pathnames (format nil "fonts/~A" file) *resources*))