(import chicken scheme srfi-1 data-structures) (use srfi-19 irregex anaphora srfi-13 (prefix format format:) numbers crypt http-session (only waffle waffle-sxml->html add-widget get-widget widgets widget-rules) doctype (only sequences intersection difference) spiffy-cookies (only uri-common uri-reference uri-encode-string uri-path uri->string) spiffy intarweb (prefix format format:) postgresql sql-null matchable srfi-69 typed-records posix srfi-18 sxml-transforms sxpath-lolevel html-parser utils simple spiffy-request-vars general-utils multipart-form-data (except http-client delete-cookie!) sort-combinators medea (prefix utf8 utf8:) (prefix utf8-srfi-13 utf8:)) ;; (use debug-utils srfi-1 box) (define *program-config* (with-input-from-file "news.conf" read)) (enable-session #t) (define (debug a . r) (log-to (debug-log) (with-output-to-string (lambda () (apply print a r))))) (define (production?) (equal? (alist-ref 'server-type *program-config*) 'production)) (let ((expires (string->time "Tue May 21 13:46:22 GMT 2020"))) (cond ((production?) (session-cookie-setter (lambda (sid) (set-cookie! (session-cookie-name) sid path: (uri-reference "/") secure: #t http-only: #t expires: expires)))) ((session-cookie-setter (lambda (sid) (set-cookie! (session-cookie-name) sid path: (uri-reference "/") expires: expires)))) (else (error "unknown server type")))) (include "db.scm") (import (prefix news-db db:)) (define with-db db:with-db) (define with-db/transaction db:with-db/transaction) (db:connection-spec (alist-ref 'db-connection-spec *program-config*)) (define user-id (make-parameter #f)) (define (define-widget-page path contents #!key css title doctype headers charset (method 'GET) use-session) (define-page path (lambda args (parameterize ((widgets *widgets*) (widget-rules *widget-rules*)) (apply contents args))) css: css title: title doctype: doctype method: method headers: headers charset: charset)) (include "global.scm") (include "macros.scm") (import news-macros) (define (subscribe-to-mailing-list email) (let* ((uri (uri-reference "https://us11.api.mailchimp.com/3.0/lists/59a8a7b46b/members/")) (req (make-request method: 'POST uri: uri headers: (headers '((content-type application/json) (authorization #(basic ((username . "foo") (password . "08707fcc5729fcd8add03466f29eec0a-us11"))))))))) (with-input-from-request req (json->string `((email_address . ,email) (status . "subscribed"))) read-string))) (define-record article (article-id : number) (title : string) (author : string) date (content : string) (description : string) (tags : list) (rank : number) (cover-image-path : string) (cover-image-width : number) (cover-image-height : number) (has-cover-image : boolean) (url : string) (points : number) (source-name : string) (source-icon-path : string) (source-id : number) (authors : list)) (define *tags* (with-db (lambda (conn) (db:get-tags-by-rank conn)))) (define *categories* `("top-news" ,@*tags*)) (define *sources* '()) (define (refresh-sources!) (set! *sources* (with-db (lambda (conn) (db:get-sources conn))))) (refresh-sources!) (define (source-ids) (map (cut alist-ref 'source-id <>) *sources*)) (define (source-names) (map (cut alist-ref 'name <>) *sources*)) (define (default-sources-ids) (map (cut alist-ref 'source-id <>) *sources*)) (define (default-sources) *sources*) (define default-possible-sources (make-parameter (filter (lambda (s) (member (alist-ref 'name s) '("New York Times" "Wall Street Journal" "Los Angeles Times" "San Francisco Chronicle" "Fox News" "NPR"))) *sources*))) (define (sources->source-id-list sources) (map (cut alist-ref 'source-id <>) sources)) (include "widgets") (define (refresh-articles!) (with-db/transaction (lambda (conn) (db:refresh-article-view! conn)))) (define (add-user conn email #!key password) (if email (with-transaction conn (lambda () (let ((id (car (row-values (if (not password) (db:add-user! conn email) (let ((salt (crypt-gensalt))) (db:add-user! conn email hash: (crypt password salt) salt: salt))))))) id))) (error "email must be specified"))) (define (account-exists? conn email) (db:account-exists? conn email)) (define (create-account conn email password) (if (not (account-exists? conn email)) (with-transaction conn (lambda () (let ((salt (crypt-gensalt))) (db:create-account! conn ($session 'user-id) email (crypt password salt) salt)))) (signal db:account-exists-condition))) (define (valid-password? conn email password) (and-let* ((db-password-and-salt (db:get-password-and-salt conn email)) (_ (> (row-count db-password-and-salt) 0))) (string=? (crypt password (value-at db-password-and-salt 1 0)) (value-at db-password-and-salt 0 0)))) (define (article-upvoted? conn user-id article-id) (> (row-count (db:article-upvoted? conn user-id article-id)) 0)) (define (signed-in? conn sid) (db:user-signed-in? conn (user-id) sid)) (define (sign-in-user user-id) ($session-set! 'user-id user-id)) (define (user-has-subscriptions conn) (db:user-has-subscriptions conn (user-id))) (define (subscriptions conn) (db:get-user-subscriptions conn (user-id))) (define (subscribe conn #!key source-id source-name type value value-id) (cond ((equal? type 'tag) (db:user-tag-subscribe! conn value (user-id) source-id)) ((equal? type 'author) (db:user-author-subscribe! conn value-id (user-id))) (else (error (conc "type not supported: " type))))) (define (unsubscribe conn #!key source-id source-name type value value-id) (if (equal? type 'tag) (db:user-tag-unsubscribe! conn value (user-id) source-id) (db:user-author-unsubscribe! conn value-id (user-id)))) (define (subscribed? conn type #!key value-name value-id source-ids) (not (null? (filter (lambda (l) (or (and (equal? type 'tag) (equal? (alist-ref 'value-name l) value-name) (member (alist-ref 'source-id l) source-ids)) (and (equal? type 'author) (equal? value-id (alist-ref 'value-id l))))) (subscriptions conn))))) (define (get-top-bar-items conn user-id) (append '(((text . "Top News") (uri . "/") (id . "top-news"))) (map (lambda (item) (let ((string-id (string-intersperse (string-split (alist-ref 'value item) " ") "-"))) (if (eq? (alist-ref 'type item) 'tag) `((text . ,(string-titlecase (alist-ref 'value item))) (uri . ,(++ "/" string-id)) (id . ,string-id)) `((text . ,(alist-ref 'value item)) (uri . ,(uri->string (uri-reference (conc "/author/" string-id "/" (alist-ref 'id item))))) (id . ,string-id))))) (db:get-user-headers conn user-id)))) (define (user-sources conn) (let ((r (db:get-user-sources conn ($session 'user-id)))) (if (null? r) (default-possible-sources) r))) (define (set-user-sources! conn sources) (db:set-user-sources! conn ($session 'user-id) sources)) (session-storage-initialize (lambda () ;; TODO check for table and columns #t)) (session-storage-set! (lambda (sid session-item) (with-transaction (db:db-connection) (lambda () (let ((bindings (session-item-bindings session-item))) (if (and (not (null? bindings)) (null? (cdr bindings)) (assoc 'user-id bindings)) (db:set-session-user-id! (db:db-connection) sid (alist-ref 'user-id bindings) (session-item-expiration session-item)) (abort (conc "Invalid session item set. Only user-id is allowed.\n" bindings)))))))) (session-storage-session-create! (lambda (sid session-item) (with-transaction (db:db-connection) (lambda () (let ((user-id (add-user (db:db-connection) sid))) (db:set-session-user-id! (db:db-connection) sid user-id (session-item-expiration session-item) new-session: #t)))))) (session-storage-session-id-exists (lambda (sid) (db:session-exists? (db:db-connection) sid))) (session-storage-ref (lambda (sid) (when (not sid) (abort (conc "session id not specified"))) (let ((r (db:get-session-user-id (db:db-connection) sid))) (if (and (> (row-count r) 0) (= (length (row-values r)) 2)) ;; expiration, ip address, bindings, finalizer (make-session-item (car (row-values r)) "" `((user-id . ,(cadr (row-values r)))) #f) (abort (conc "session id not found: " sid)))))) (session-storage-delete! (lambda (sid) (with-transaction (db:db-connection) (lambda () (db:session-delete! (db:db-connection) sid))))) (define (waffle-sxml->string sxml) (with-output-to-string (lambda () (waffle-sxml->html sxml)))) (define (text->sxml text) (map (lambda (para) `(p ,para)) (string-split text "\n" #f))) ;; for use with getting articles from the db and row-fold* (define (make-article* id title author date description rank content cover-image-path cover-image-height cover-image-width tags points url source-name source-icon-path source-id authors author-ids o) (cons (let ((article (make-article id title author date (with-input-from-string content (lambda () (read))) description (vector->list tags) rank cover-image-path cover-image-height cover-image-width (not (sql-null? cover-image-path)) url points source-name (if (string=? source-icon-path "") "" (++ (uri->string (uri-source-icon)) "/" source-icon-path)) source-id (if (or (sql-null? authors) (null? authors) (sql-null? (vector-ref authors 0))) '() (map (lambda (author/id) `((name . ,(car author/id)) (id . ,(cadr author/id)))) (zip (vector->list authors) (vector->list author-ids))))))) article) o)) (define (get-article-by-id conn id) (car (apply make-article* (append (db:get-article-by-id conn (if (string? id) (string->number id) id)) '(nil))))) (define (get-articles-by-tags conn tags #!key limit exclude-ids (sort 'date) (sources (default-sources))) (reverse (row-fold* make-article* '() (db:get-articles-by-tags conn tags limit: limit exclude-ids: exclude-ids sources: (sources->source-id-list sources) sort: sort)))) (define (get-articles-by-popularity conn #!key tags limit offset exclude-ids (sources (default-sources))) (reverse (row-fold* make-article* '() (db:get-articles-by-popularity conn tags: tags exclude-ids: exclude-ids offset: offset limit: limit sources: (sources->source-id-list sources))))) (define (get-users-articles conn user-id limit #!key rank tag) (reverse (row-fold* make-article* '() (db:get-users-articles conn user-id limit rank: rank tag: tag)))) (define (get-feed-articles conn type tags #!key exclude-ids (sources (default-sources-ids)) (limit 6)) (get-articles-by-tags conn tags limit: limit exclude-ids: exclude-ids sources: sources)) (define (get-articles-by-authors conn authors #!key limit (exclude-ids '())) (reverse (row-fold* make-article* '() (db:get-articles-by-authors conn authors limit: limit exclude-ids: exclude-ids)))) (define (get-top-article conn tags #!key exclude-ids (sources (default-sources))) (car (get-articles-by-tags conn tags limit: 1 sort: 'rank exclude-ids: exclude-ids sources: sources))) (define (mark-article-viewed conn id) (db:mark-article-viewed! conn id ($session 'user-id)) (db:increment-article-viewed! conn id)) (define (articles-viewed-by-user conn user-id) (db:get-articles-viewed-by-user conn user-id)) (define (user-tags conn user-id) (column-values (db:user-tags conn user-id))) (define (user-categories conn) (append '("top-news") (user-tags conn ($session 'user-id)))) (define (feed-title-by-type type) (if (eq? type 'popular) "Most popular" "Latest news")) (define (get-image-dimensions path) (map string->number (string-split (with-input-from-pipe (string-append "identify -format \"%w,%h\" " path) read-string) ","))) (define (maybe-create-session) (unless (and (connection? (db:db-connection)) (connected? (db:db-connection))) (db:db-connection (connect (db:connection-spec)))) (let ((ua (header-value 'user-agent (request-headers (current-request)) '()))) (when (and (not (null? ua)) (>= (length (car ua)) 3) (or (equal? (third (car ua)) "http://www.pingdom.com/") (string-contains (third (car ua)) "bot"))) (sid "4706c04b9436d8bbab979fea126b9375c49ab039"))) (when (not (session-valid? (sid))) (if (session-valid? (read-cookie (session-cookie-name))) (sid (read-cookie (session-cookie-name))) (begin (sid (session-create)) ((session-cookie-setter) (sid)) (set-user-sources! (db:db-connection) (default-possible-sources)) (db:set-new-user-default-interests! (db:db-connection) ($session 'user-id) 455000) ;; (db:set-user-default-interests! ;; (db:db-connection) ($session 'user-id) ;; "default-common") ))) (user-id ($session 'user-id))) (add-request-handler-hook! 'foo (lambda (path handler) (unless (and (connection? (db:db-connection)) (connected? (db:db-connection))) (db:db-connection (connect (db:connection-spec)))) (maybe-create-session))) (add-post-request-handler-hook! 'foo (lambda (path handler) (disconnect (db:db-connection)) (db:db-connection #f))) (define (get-minutes date) (/ (time-second (date-difference (current-date) date)) 60)) (get-minutes (make-date 0 0 0 0 1 2 2015)) (define-inline (num->int n) (inexact->exact (ceiling n))) (define (time-in-past-natural date) (let ((minutes (/ (time-second (date-difference (current-date) date)) 60))) (cond ((< minutes 60) (string-append (number->string (num->int minutes)) " minutes ago")) ((< minutes 1440) ; less than 1 day (string-append (number->string (num->int (/ minutes 60))) " hours ago")) ((< minutes 10080) ; less than 1 week (format:format #f "~D day~:P ago" (num->int (/ minutes 1440)))) ((< minutes 43200) ; less than 1 month (format:format #f "~D week~:P ago" (num->int (/ minutes 10080)))) ((< minutes 525600) (format:format #f "~D month~:P ago" (num->int (/ minutes 43200)))) (else (format:format #f "~D year~:P ago" (num->int (/ minutes 524160))))))) (define *pre-generated-html* (with-input-from-file (filesystem-path-pre-generated-html) (lambda () (read)))) (define *news-javascript-html* (alist-ref 'news-javascript-html *pre-generated-html*)) (define *news-load-javascript* (alist-ref 'news-load-javascript *pre-generated-html*)) (define *ga-tracking-code* "") (define *heap-tracking-code* (if (production?) " " " ")) (define *headers* (with-output-to-string (lambda () (display "
") (waffle-sxml->html `((meta (@ (http-equiv "Content-Type") (content "text/html; charset=utf-8"))) (meta (@ (name "description") (content "Up-to-date news aggregated from online newspapers, blogs, and aggregators personalized for you."))) (literal ,(alist-ref 'news-stylesheet-html *pre-generated-html*)) (meta (@ (name "viewport") (content "width=device-width, initial-scale=1.0")))))))) (define (page-type uri) (let ((path (uri-path uri))) (cond ((or (equal? (uri-path (uri-site-root)) path) (equal? '() path)) 'top-news) ((and (or (= (length path) 2) (= (length path) 3)) (member (second path) *tags*)) 'tag) ;; ((and-let* ((_ (= (length path) 4)) ;; (_2 (string=? (second path) "author")) ;; (author-id (string->number (fourth path))) ;; (db-result (db:get-author-name-by-author-id ;; (db:db-connection) author-id)) ;; (author-found (> (row-count db-result) 0)) ;; (author-name (value-at db-result))) ;; #t) 'author) (else #f)))) (define (add-attribute sxml attribute-sxml) (if (not (member 'visible (flatten sxml))) (if (and (not (null? (cdr sxml))) (list? (cadr sxml)) (not (null? (cadr sxml))) (eq? (caadr sxml) '@)) `(,(car sxml) (@ (visible #t) ,@(cdadr sxml)) ,@(cddr sxml)) `(,(car sxml) (@ (visible #t)) ,(cdr sxml))) sxml)) (define (text->seo-friendly-url text) (uri-encode-string (irregex-replace/all "\\s" (irregex-replace/all "[^ A-Za-z0-9_]" (string-downcase text) "") "-"))) (define (sxml->longest-strings sxml) (fold (lambda (e o) (if (or (string? e) (char? e) (symbol? e)) (if (and (not (null? o)) (string? (car o))) (cons (conc e (car o)) (cdr o)) (cons (->string e) o)) (cons e o))) '() (reverse (flatten (pre-post-order* sxml universal-conversion-rules*))))) (define (full-widget-rules) `((*text* . ,(lambda (tag str) str)) (*default* . ,cons) (*TOP* . ,(lambda (tag str) str)) . ,*widget-rules*)) (define (compile-sxml sxml) (sxml->longest-strings (pre-post-order* sxml (full-widget-rules)))) (define (clambda sxml) (let ((s (compile-sxml sxml))) (lambda () (SRV:send-reply s)))) (define (example-sxml-compilation) (SRV:send-reply (compile-sxml `(div (span "hi" ,(clambda `(nav "foo"))))))) (define (thumbnail-height height width ideal) (let* ((ideals '((none . 0) (extra-small . 50) (small . 80) (medium . 120) (large . 200) (extra-large . 320))) (ideal-num (alist-ref ideal ideals)) (max-levels (map cdr (filter (lambda (p) (and (<= (cdr p) ideal-num) (>= height (cdr p)))) ideals))) (actual-max (apply max max-levels)) (ideal-by-num (map (lambda (p) `(,(cdr p) . ,(car p))) ideals))) (alist-ref actual-max ideal-by-num))) (widget 'thumbnail `(let ((actual-size (thumbnail-height height width size))) `(div (@ (style ,(++ "background: url('/res/img" src "') no-repeat center center;")) (class (,(string-append "thumbnail " (cond ((eq? actual-size 'large) "thumbnail-large") ((eq? actual-size 'extra-large) "thumbnail-extra-large") ((eq? actual-size 'medium) "thumbnail-medium") ((eq? actual-size 'small) "thumbnail-small") ((eq? actual-size 'extra-small) "thumbnail-extra-small") (else "")) (if class (conc " " class) ""))))))) `((src "") (load-by-default #t) (size 'large) (width #f) (height #f) (class #f))) (widget 'top-bar-header ``(row (@ (no-padding #t)) (col (@ (small 3)) (span (@ (class ,(conc "fa fa-bars fa-fw fa-2x menu-bars" (if show-menu "" " hidden"))) (id "menu-bars")))) (col (@ (small 6)) (a (@ (href ,(uri->string (uri-site-root)))) (center (img (@ (src ,logo-uri) (height ,logo-height) (width ,logo-width) (class "logo-normal")))))) ,@(if show-personalize `((col (@ (small 3)) (a (@ (href ,(uri->string (update-uri (uri-personalize) query: `((selected . ,page)))))) (button (@ (class "button secondary button-small right")) "personalize")) (div (@ (id "personalize-tooltip")) (div (@ (class "triangle-up"))) (p "Click on personalize to add, remove, or change your topics and sources.") (center (button (@ (id "close-tooltip") (class "button")) "OK got it"))))) '()) (col-end)) `((logo-height "44") (logo-width "126") (page "") (show-menu #t) (show-personalize #t) (logo-uri ,(alist-ref 'news-logo-normal-uri *pre-generated-html*)))) (widget 'menu ``(div (@ (class "menu-container") (id "menu-container")) (row (@ (no-padding #f)) (col (@ (small 12) (medium 6) (large 3)) (div (@ (class "menu-header")) "Account") (ul ,@(if signed-in `((li (a (@ (href ,(uri->string (uri-account-sign-out)))) (span (@ (class "fa fa-sign-out"))) "Sign out"))) `((li (a (@ (href ,(uri->string (uri-account-create)))) (span (@ (class "fa fa-user-plus"))) "Create account")) (li (a (@ (href ,(uri->string (uri-account-sign-in)))) (span (@ (class "fa fa-sign-in"))) "Sign in")))) (li (a (@ (href ,(uri->string (uri-account-user-metrics-opt-out)))) (icon (@ (icon "area-chart"))) "Analytics and tracking")))) (col-end))) `((signed-in #f) (unviewed-only #f))) (widget 'filter-item-standard ``(a (@ (href ,uri) (class "js-filter-item-link")) (div (@ (class ,(string-append "filter-item" (if (or (string=? id selected)) " filter-item-selected" ""))) (id ,(string-append "filter-item-" id))) ,@contents)) '((uri "") (selected "") (id ""))) (widget 'filter-item-personalize `(begin `(a (@ (href "javascript:void(0);") (class "js-filter-item-link")) (div (@ (class ,(string-append "filter-item" (if (or (string=? id (string-intersperse (string-split selected " ") "-"))) " filter-item-selected" "") " js-filter-item-link-personalize")) (id ,(string-append "filter-item-" id)) (data-id ,id)) ,@contents))) '((uri "") (selected "") (id ""))) (widget 'filter-items ``(row (@ (no-padding #t)) (col (@ (small 12)) (div (@ (class "filter-tags-container")) ,@contents)) (col-end)) `()) (widget 'standard-top-bar-items `(map (lambda (item) `(filter-item-standard (@ (uri ,(alist-ref 'uri item)) (selected ,selected) (id ,(alist-ref 'id item))) ,(string-titlecase (irregex-replace/all "-" (alist-ref 'text item) " ")))) items) '((items ()) (selected ""))) (widget 'personalize-top-bar-items `(map (lambda (item) `((filter-item-personalize (@ (uri ,(alist-ref 'uri item)) (selected ,selected) (id ,(alist-ref 'id item))) ,(if (equal? (alist-ref 'id item) "top-news") "All Topics" (string-titlecase (irregex-replace/all "-" (alist-ref 'text item) " ")))))) items) '((items ()) (selected ""))) (widget 'top-bar ``(div (@ (id "top-bar-container") (class "top-bar-container")) (top-bar-header (@ (page ,page) (show-menu ,show-menu) (show-personalize ,show-personalize))) (menu (@ (signed-in ,signed-in) (unviewed-only ,show-unread-only))) ,@contents) `((filterable #t) (signed-in #f) (show-unread-only #f) (page "") (show-menu #t) (show-personalize #t))) (widget 'all-news-personalize-section `(let ((subscribed-ids (map (lambda (i) (alist-ref 'source-id i)) subscriptions))) `(row (col (@ (small 12) (large 6) (large-offset 3)) (column-title "Enable or disable sources across all topics") ,@(map (lambda (source) `(row (col (@ (small 12)) (follow-button (@ (show-names #t) (name ,(alist-ref 'name source)) (source-id ,(alist-ref 'source-id source)) (icon-path ,(alist-ref 'icon-path source)) (value "") (type "source") (add ,(not (member (alist-ref 'source-id source) subscribed-ids))))) ))) possible-user-sources)))) '((subscriptions ()) (possible-user-sources ()))) (widget 'personalize-topic-section ``(row (col (@ (small 12) (large 6) (large-offset 3)) (center (a (@ (href "javascript:void(0);") (data-value ,(alist-ref 'tag-name (car sources))) (data-state "remove") (data-type "topic") (class "remove-topic")) (icon (@ (icon "remove") (class "topic-icon-remove"))) (icon (@ (icon "undo") (class "topic-icon-add display-none"))) " " (span (@ (class "topic-remove-text")) "Unsubscribe from " ,(string-titlecase (irregex-replace/all "-" (alist-ref 'tag-name (car sources)) " "))))) (column-title "Select sources") ,@(let ((source-ids (map (lambda (sub) (alist-ref 'source-id sub)) subscriptions))) (map (lambda (source) `(row (col (@ (small 12)) (follow-button (@ (show-names #t) (name ,(alist-ref 'source-name source)) (source-id ,(alist-ref 'source-id source)) (icon-path ,(alist-ref 'source-icon-path source)) (add ,(not (member (alist-ref 'source-id source) source-ids))) (value ,(alist-ref 'tag-name source)))) ))) sources)))) '((sources ()) (subscriptions ()))) (widget 'personalize-section-remove-item ``(center (a (@ (href "javascript:void(0);") (data-value ,value) (data-state "remove") (data-id ,id) (data-type ,type) (class "remove-topic")) (icon (@ (icon "remove") (class "topic-icon-remove"))) (icon (@ (icon "undo") (class "topic-icon-add display-none"))) " " (span (@ (class "topic-remove-text")) "Unsubscribe from " ,(string-titlecase (irregex-replace/all "-" text " "))))) '((value "") (type "") (text "") (id ""))) (widget 'personalize-author-section ``(row (col (@ (small 12) (large 6) (large-offset 3)) (personalize-section-remove-item (@ (type "author") (value ,name) (text ,name) (id ,id))))) '((name "") (id ""))) (widget 'select-interests-items ``(ul (@ (class "extra-interests-container")) ,@(let ((items* (map-in-order (lambda (item) (let ((id (conc "selection-checkbox-" (random 10000000)))) `(div (@ (class ,(++ "selection-checkbox-container " "interest-element-container" (if show "" " display-none"))) (data-name ,(alist-ref text-key item))) (input (@ (type "checkbox") (class "selection-checkbox interest-item-input") (id ,id) (data-type ,type) (data-valueid ,(alist-ref id-key item)) (data-value ,(alist-ref text-key item)) ,@(if source-key `((data-sourceid ,(alist-ref source-key item))) '()) ,@(if (member (alist-ref text-key item) selected) `((checked)) '()))) (label (@ (for ,id)) (div (@ (class "selection-label-container")) ,(string-titlecase (irregex-replace/all "-" (alist-ref text-key item) " "))))))) (sort items (lambda (i1 i2) (if (assoc 'tag-rank i1) (< (alist-ref 'tag-rank i1) (alist-ref 'tag-rank i2)) (string< (alist-ref 'author-name i1) (alist-ref 'author-name i2)))))))) `(,@(if (not (null? items*)) `((li (@ (id "interest-element-header-topics")) (strong ,title))) '()) ,@items*))) '((items ()) (text-key nil) (id-key nil) (source-key #f) (title "") (selected ()) (type "") (show #t))) (widget 'select-interests ``(div (@ (class "select-interests-container") (id "select-interests-container")) (row (@ (no-padding #t) (id "default-topics-container")) ,@(map (lambda (e) `(col (@ (small 6) (large 4)) (div (@ (class "interest-image-container") (data-type "topic") (data-valudid ,(alist-ref 'tag-id e)) (data-value ,(alist-ref 'tag-name e))) (div (@ (class "interest-image-overlay"))) (img (@ (src ,(++ (uri->string (uri-img-icon)) "/" (alist-ref 'tag-name e) "-square.jpg")))) (div (@ (class "interest-image-checkmark-container")) (icon (@ (icon "check-circle") (class "display-none"))) (icon (@ (icon "plus-circle")))) (div (@ (class "interest-image-text")) (center ,(alist-ref 'tag-name e)))))) common) (col (@ (small 12)) (hr))) (select-interests-items (@ (items ,tags) (text-key tag-name) (title "Topics") (selected ,tags-selected) (type "topic") (source-key source-id) (id-key tag-id))) (div (@ (id "author-interests-container")))) '((common ()) (tags ()) (tags-selected ()))) (widget 'article-card-info-row ``(row (@ (no-padding #t)) (col (@ (no-padding #t) (small 6)) ,@(if show-upvote `((span (@ (class ,(string-append "fa fa-lg fa-thumbs-up upvote " "article-" (number->string id) "-upvote")) (data-article-id ,id)))) '()) ,@(if points ;; An article's points are cached. If the user has ;; upvoted an article they expect the point value ;; to increase. But if the article's point value ;; hasn't been updated since then it will not ;; appear that their upvote worked. So we fake it ;; by adding a point if they have upvoted the ;; article. Technically it can give an inflated ;; number but shouldn't matter. `((span (@ (class ,(string-append "article-" (number->string id) "-points"))) ,(if show-upvote points (+ points 1))) ,(format:format #f " point~P" (if show-upvote points (+ points 1)))) '())) (col (@ (small 6)) (span (@ (style "color: #aaa;") (class "right")) ,(time-in-past-natural date)))) `((show-upvote #f) (id 'nil) (points 5) (date date))) (widget 'article-card-author-list `(fold (lambda (author o) `((a (@ (href ,(++ (uri->string (uri-author)) "/" (text->seo-friendly-url (alist-ref 'name author)) "/" (number->string (alist-ref 'id author)))) (data-viewed-ignore "true")) ,(alist-ref 'name author)) ,@(if (null? o) '() '(" and ")) ,@o )) '() authors) '((authors ()))) (widget 'article-source-line ``(p (@ (class "tiny-font source-line")) ,@(if (string=? (article-source-icon-path article) "") '() `((img (@ (src ,(article-source-icon-path article)) (width "16px") (height "16px"))))) (span ,(article-source-name article))) '((article #f))) (widget 'article-image-title-row ``(div (@ (class "flex-container flex-align-center")) (a (@ (href ,(article-url article)) (class "unstyled-link")) ,(if (article-has-cover-image article) `(thumbnail (@ (src ,(string-append "/original/" (article-cover-image-path article))) (style "display: block; margin: 0 auto;") (load-by-default #f) (height ,(article-cover-image-height article)) (width ,(article-cover-image-width article)) (size large))) `(thumbnail (@ (src "/icons/image-filler.svg") (style "display: block; margin: 0 auto;") (height 100) (width 100) (size large) (class "filler-image"))))) (a (@ (href ,(article-url article)) (class "unstyled-link")) (,header-size (@ (class "headline")) ,(article-title article)))) '((article #f) (header-size h3))) (widget 'article-card `(let* ((_type-properties '((featured . ((header-size . h2) (show-img . #t) (font-class . "") (thumbnail-size . extra-large) (show-details . #t) (show-img-small . #t) (show-img-medium . #t) (description-length . 500))) (large . ((header-size . h3) (show-img-medium . #t) (font-class . "") (thumbnail-size . medium) (show-details . #t) (show-img-small . #t) (description-length . 300))) (normal . ((header-size . h5) (show-img-medium . #t) (font-class . "") (thumbnail-size . medium) (show-details . #t) (show-img-small . #t) (description-length . 100))) (small . ((header-size . h6) (show-img-medium . #t) (show-img-small . #t) (font-class . "") (thumbnail-size . small) (show-details . #f))) (tiny . ((header-size . h6) (show-img-medium . #f) (show-img-small . #f) (font-class . "small-font") (show-details . #f))))) (type-properties (alist-ref card-type _type-properties)) (default-description-length 100)) (define (truncate-description d len) (++ (utf8:string-take d (min (utf8:string-length d) len)) "...")) `(row (@ (no-padding #t)) (div (@ (class ,(string-append "column small-12 js-article-card article-card" (if (article-has-cover-image article) " article-card-with-image" " article-card-without-image") (if viewed " article-card-viewed" "") (if (eq? card-type 'featured) " article-card-featured" ""))) (data-article-id ,(article-article-id article)) (data-article-path ,(article-url article)) (id ,(string-append "article-card-" (number->string (article-article-id article))))) (row (@ (no-padding #t)) (col (@ (small 12)) (row (@ (no-padding #t)) (col (@ (small 12)) (article-image-title-row (@ (article ,article) (header-size ,(alist-ref 'header-size type-properties)))))) (row (@ (no-padding #t)) (col (@ (small 12)) ,@(if (alist-ref 'show-details type-properties) `((p (@ (class "tiny-font byline")) (article-card-author-list (@ (authors ,(article-authors article)))) ,(if (or (null? (article-authors article)) (string=? (alist-ref 'name (car (article-authors article))) "")) "" " :: ") ,(time-in-past-natural (article-date article))) ,(if (and (article-description article) (not (string=? (article-description article) ""))) `(a (@ (href ,(article-url article)) (class "unstyled-link")) (p (@ (class "small-font overflow-hidden")) ,(truncate-description (article-description article) (or (alist-ref 'description-length type-properties) default-description-length)))) "")) '()))) (article-source-line (@ (article ,article)))))))) `((viewed #f) (display-tags ()) (card-type normal) (points #f) (show-upvote #f) (category "none") (article 'nil) (show-source #t))) (widget 'column-title ``(row (@ (no-padding #t)) (col (@ ,@col-size) ,(if (equal? contents '("")) `(div (@ (class "empty-column-title"))) (if link-uri `(a (@ (href ,(uri->string link-uri))) (h5 (@ (class "column-title")) ,@contents)) `(h5 (@ (class "column-title")) ,@contents))))) `((col-size ((small 12))) (link-uri #f))) (widget 'feed `(if (null? articles) '() `(div (@ (id ,(++ "feed-container-" (or category ""))) (style ,(if style style "")) (class ,class)) (row (@ (no-padding #t)) (col (@ (small 12)) (column-title (@ (link-uri ,(if link-uri link-uri #f))) ,title))) ,@(map (lambda (article) `(article-card (@ (article ,article) (viewed ,(member (article-article-id article) viewed-articles)) (points ,(article-points article)) (card-type ,(if (and card-type-for-first _first-card) (begin (set! _first-card #f) card-type-for-first) card-type)) (category ,(or category "")) (show-upvote ,(not (member (article-article-id article) upvoted-articles))) (show-source ,(not (or (null? sources) (and (not (null? sources)) (null? (cdr sources)))))) (display-tags ,(filter (lambda (t) (not (string=? t category))) (article-tags article)))))) articles))) `((category "") (style #f) (title "") (articles ()) (card-type normal) (card-type-for-first #f) (_first-card #t) (style #f) (class ()) (upvoted-articles ()) (viewed-articles ()) (link-uri #f) (sources ()))) (widget 'follow-button ``(label (@ (class "add-follow-label")) (input (@ (class "follow-icon-container add-follow") (type "checkbox") (data-type ,type) (data-value ,value) (data-value-id #f) (data-source-id ,source-id) (data-source-name ,name) (data-state ,(if add "add" "remove")) ,@(if add '() '((checked))))) (img (@ (src ,(++ (uri->string (uri-source-icon)) "/" icon-path)) (width "18px") (height "18px"))) " " ,name) '((add #t) (type "tag") (value "") (source-id "") (name "") (icon-path ""))) (widget 'subscribe-to-tag-body ``(span (div (@ (class "follow-icons-outer-container")) ,@(map (lambda (s) `(follow-button (@ (items ,s) (value ,tag) (add ,(null? (filter (lambda (f) (and (equal? (alist-ref 'type f) 'tag) (equal? (alist-ref 'source-id s) (alist-ref 'source-id f)) (string=? (alist-ref 'value f) tag))) subscriptions)))))) sources))) '((category "") (sources ()) (subscribed-to-ids ()) (tag "") (subscriptions ()))) (widget 'subscribe-to-author-body ``(span (div (@ (class "follow-icons-outer-container")) (button (@ (class "button follow-icon-container add-follow") (data-type "author") (data-value ,name) (data-value-id ,author-id) (data-state ,(if add "add" "remove"))) (span (@ (class ,(++ "follow-icon-add " (if add "" "display-none")))) (icon (@ (icon "plus")))) (span (@ (class ,(++ "follow-icon-remove " (if add "display-none" "")))) (icon (@ (icon "check-square")))) " " ,name))) '((name "") (subscriptions ()) (add #t) (author-id -1))) (widget 'select-items ``(span (div (@ (class "checkbox-icons-outer-container")) ,@(map (lambda (s) (let ((id (conc "selection-checkbox-" (random 10000000)))) `(div (@ (class "selection-checkbox-container")) (input (@ (type "checkbox") (class "selection-checkbox") (id ,id) (name ,input-name) (value ,(alist-ref 'value s)) ,@(if (and (assoc 'checked s) (alist-ref 'checked s)) `((checked)) '()))) (label (@ (for ,id)) (div (@ (class "selection-label-container")) ,@(if (assoc 'icon-path s) `((img (@ (src ,(++ (uri->string (uri-source-icon)) "/" (alist-ref 'icon-path s))) (width "18px") (height "18px")))) '()) " " ,(alist-ref 'text s)))))) item-map))) ;; item-map looks like -> ;; (((text . "NY Times") (value . "1") (icon-path . "/foo") ;; ((text . "LA Times") (value . "6"))) ;; icon-path is optional '((item-map ()) (input-name ""))) (widget 'flow-button ``(button (@ (type "submit") (class "flow-button")) ,@contents " " (icon (@ (icon "arrow-right")))) '()) (widget 'subscribe-to-button ``(column-layout (div (@ (class "center")) ,@contents)) '()) (widget 'lightbox ``(div (@ (class ,(++ "lightbox-body-container" ))) (div (@ (class ,(++ "lightbox-body" (if full-width " lightbox-full-width" ""))) ,@(if id `((id ,id)) '())) (div (@ (class "right close-lightbox")) (icon (@ (icon "close") (class "fa-2x")))) (spacer) ,@contents)) '((id #f) (full-width #f))) (widget 'main-page ``(div (@ (id "main-page-outer-container")) (row (@ (id "main-page-container")) (col (@ (small 12) (medium 8)) (row (@ (no-padding #t)) (col (@ (small 12)) (feed (@ (class "sub-feed-container") (upvoted-articles ,upvoted-articles) (title "Top News") (viewed-articles ,viewed-articles) (articles ,top-articles) (sources ,sources) (card-type normal) (card-type-for-first normal)))))) (col (@ (id "feed-parent-container") (small 12) (medium 4) (large 4) (class "")) (feed (@ (category ,category) (title ,(++ (feed-title-by-type type) (if (and category (member category *tags*)) " >" ""))) (upvoted-articles ,upvoted-articles) (viewed-articles ,viewed-articles) (articles ,articles) (sources ,sources) (link-uri ,(if (and category (member category *tags*)) (let ((uri (uri-reference (++ "/" category)))) (update-uri uri path: `(,@(uri-path uri) "latest"))) #f))))) (col-end))) `((tags ("")) (type tag) (top-articles #f) (category #f) (articles ()) (upvoted-articles ()) (viewed-articles ()) (sources ()) (subscribed-to-ids ()) (subscriptable #t) (subscriptions ()))) (widget 'feed-section ``(col (@ (id "feed-parent-container") (small 12) (medium 10) (large 8) (medium-offset 1) (large-offset 2) (class "")) (feed (@ (category ,(if category category "")) (title ,title) (upvoted-articles ,upvoted-articles) (viewed-articles ,viewed-articles) (articles ,articles) (card-type ,card-type)))) '((category "") (title "") (upvoted-articles ()) (viewed-articles ()) (articles ()) (card-type normal))) (widget 'latest-page ``(div (@ (id "main-page-outer-container")) (row (@ (id "main-page-container")) (feed-section (@ (type ,type) (category ,category) (title ,title) (articles ,articles) (upvoted-articles ,upvoted-articles) (viewed-articles ,viewed-articles) (card-type featured))) (col-end))) `((type tag) (category "") (title "") (articles ()) (upvoted-articles ()) (viewed-articles ()))) (widget 'author-page ``(div (@ (id "main-page-outer-container")) ;; (subscribe-to-button ;; (subscribe-to-author-body ;; (@ (subscriptions ,subscriptions) ;; (name ,author) ;; (author-id ,author-id) ;; (add ;; ,(null? ;; (filter (lambda (f) ;; (and (equal? (alist-ref 'type f) 'author) ;; (string=? (alist-ref 'value f) author))) ;; subscriptions)))))) (row (@ (id "main-page-container")) (feed-section (@ (category ,category) (title ,title) (articles ,articles) (upvoted-articles ,upvoted-articles) (viewed-articles ,viewed-articles) (card-type featured))) (col-end))) `((category #f) (title "") (author "") (subscriptions ()) (author-id -1) (articles ()) (upvoted-articles ()) (viewed-articles ()))) (widget 'select-sources ``(span (div (@ (class "interest-icons-outer-container")) ,@(map (lambda (s) `(follow-button (@ (s ,s) (show-names #t) (add ,(null? (filter (lambda (f) (and (equal? (alist-ref 'type f) 'tag) (equal? (alist-ref 'source-id s) (alist-ref 'source-id f)) (string=? (alist-ref 'value f) tag))) subscriptions))) ))) sources))) '((sources ()) (subscriptions ()))) (widget 'accordion-container ``(ul (@ (class "accordion-container")) ,@contents) '()) (widget 'accordion ``(li (@ (class "accordion")) (h5 (@ (class "accordion-header")) (icon (@ (icon "caret-square-o-right") (class ,(conc "accordion-closed-icon " (if open "display-none" ""))))) (icon (@ (icon "caret-square-o-down") (class ,(conc "accordion-open-icon " (if open "" "display-none"))))) ,(++ " " title)) (row (@ (no-padding #t) (class ,(conc "accordion-body " (if open "" "accordion-body-hidden display-none")))) (col (@ (small 12)) ,@contents))) '((title "") (open #f))) (widget 'app ``(row (@ (no-padding #t)) (col (@ (small 12)) ,@contents)) `()) (widget 'top-fixed-container ``(div (@ (class "top-fixed-container") (id "top-fixed-container") (style ,(if visible "" "display: none;"))) (app ,@contents)) `((visible #t))) (widget 'code/highlight ``(pre (code (@ (class ,lang)) ,@contents)) '((lang ""))) (define *email-sign-up+lightbox* (with-output-to-string (lambda () (parameterize ((widgets *widgets*) (widget-rules *widget-rules*)) (waffle-sxml->html `((div (@ (class "lightbox display-none"))) (lightbox (@ (id "email-signup-container")) (h4 "Want breaking news updates just for you?") (p "Subscribe to receive custom breaking news updates and kabonky product updates.") (p (input (@ (type "email") (placeholder "email address") (id "subscribe-to-mailing-list-email-address")))) (p (label (input (@ (type "checkbox") (checked) (id "subscribe-breaking-news"))) " breaking news updates") (br) (label (input (@ (type "checkbox") (checked) (id "subscribe-product-updates"))) " product updates")) (p (button (@ (id "submit-email-sign-up") (class "button")) "subscribe"))))))))) (define *page-footer* (with-output-to-string (lambda () (parameterize ((widgets *widgets*) (widget-rules *widget-rules*)) (waffle-sxml->html `(row (col (@ (small 12)) (hr) (center "contact us at: " (a (@ (href "mailto:t@thintz.com")) "t@thintz.com"))) (col (@ (small 12)) (center "powered by " (a (@ (href "http://www.call-cc.org/")) "chicken scheme")))))) (display "") (display *news-javascript-html*) (display "")))) (define (defpage path thunk #!key title header) (define-widget-page path (lambda args (begin (replace-header-contents! 'cache-control '(#(no-cache ())) (response-headers (current-response))) (display *headers*) (display (++ "