ulthar.xyz > Repos

leibowitz

Experimental Common Lisp object storage abstraction for Unix file systems
About Files Commits git clone https://ulthar.xyz/repos/leibowitz/leibowitz.git

leibowitz/web/html.lisp

Download raw file: web/html.lisp

;;; Page generators

(in-package :leibowitz.web)

(defun html-snippet (snippet)
  (eval
   `(cl-who:with-html-output-to-string (*standard-output* nil :prologue NIL :indent t)
      ,@snippet)))

(defun make-page (lib &key here sidebar title body limit offset total header more-params)
  (eval
   `(cl-who:with-html-output-to-string (*standard-output* nil :prologue t :indent t)
      (:html
       :xmlns     "http://www.w3.org/1999/xhtml"
       :xml\:lang "en"
       :lang      "en"
       (:head (:title ,title)
              (:meta :http-equiv "Content-Type"
                     :content "text/html;charset=utf-8")
              (:meta :name "viewport" :content "width=device-width, initial-scale=1.0")
              (:link :rel "stylesheet" :href "/static/style.css" :media "screen"))
       (:body (:div :id "header-and-navbar-container"
                    (:header :id "header" ,@(if header header `((:h1 ,title))))
                    (:nav :id "navbar" :class "ui"
                          ;; Apply the "here" class to whichever link
                          ;; corresponds to the current page, if any
                          ,@(loop for link in '((:a :href "/" "All")
                                                (:a :href "/tags" "Tags")
                                                (:a :href "/tree" "Tree")
                                                (:a :href "/search" "Search")
                                                (:a :href "/new" "New"))
                                  do (when (equal (nth 2 link) here)
                                       (setf link (append (subseq link 0 3)
                                                          '(:class "here")
                                                          (last link))))
                                  collect link)))
              (:div :id "sidebar-and-content-container"
                    (:aside :id "sidebar" :class "ui" ,@sidebar)
                    (:main :id "content" ,@body))
              ,(when (and limit offset)
                 `(:nav :id "pagination" :class "ui"
                        ,@(loop with pages = (ceiling (/ total limit))
                                for p from 1 to pages
                                for pth-link-offset = (* limit (- p 1))
                                collect `(:a :class ,(if (= offset pth-link-offset)
                                                        "page-link here"
                                                        "page-link")
                                             :href ,(let ((url (format NIL "~A?limit=~A&offset=~A"
                                                                       here (url limit)
                                                                       (url pth-link-offset))))
                                                      (if more-params
                                                          (format NIL "~A&~A" url more-params)
                                                          url))
                                             ,(format NIL "~A" p)))))
              (:footer :id "footer"
                       (:hr)
                       (:a :href "https://sr.ht/~thalia/leibowitz"
                           "Project Home")
                       "//"
                       (:a :href "mailto:~thalia/leibowitz@lists.sr.ht"
                           "Report a Bug")))
              (:script :src "/static/fluff.js")))))

(defun return-404 (lib &optional msg)
  (setf (hunchentoot:return-code*) 404)
  (make-page lib :here ""
                 :title "404 Not Found | Leibowitz Web"
                 :body `((:section (:p ,(html msg))))))

(defun return-400 (lib &optional msg)
  (setf (hunchentoot:return-code*) 400)
  (make-page lib :here ""
                 :title "400 Bad Request | Leibowitz Web"
                 :body `((:section (:p ,(html msg))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defun make-datum-listing-sidebar (lib)
  `((:section
     (:h2 "Search")
     (:form :method "get" :action "/search" :class "sidebar-form"
            (:input :name "q")
            (:button "Go!")))
    (:section
     (:h2 "Top Tags")
     (:ul ,@(loop for tag in (list-tags lib :limit 15)
                  collect `(:li (:a :href ,(format NIL "/tag?name=~A"
                                                   (html (url (tag-name tag))))
                                    ,(html (tag-name tag)))
                                (:span :class "tag-count"
                                       ,(format nil "(~a)" (tag-count tag))))))
     (:a :href "/tags"
         (:b ,(format NIL "Show all ~A tags" (library-tag-quantity lib)))))
    (:section
     (:h2 "File Types")
     (:ul ,@(loop for type in (library-all-file-types lib)
                  collect `(:li (:a :href ,(format NIL "/type/~A" (car type))
                                    ,(car type))
                                (:small ,(format NIL "(~A)" (cdr type)))))))))

(defun list-data-as-html (lib view &rest options &key &allow-other-keys)
  "Beautify the output of `leibowitz.core:list-data' as a HTML datum
listing.  Key arguments are passed unmodified to that method."
  (check-type lib library)
  (let ((data (loop for datum in (apply #'list-data (nconc (list lib) options))
                    collect (datum-html-preview lib datum :view view))))
    `(,@(make-datum-listing-filter-bar view
                                       (getf options :sort-by)
                                       (getf options :direction)
                                       (getf options :dir))
      ,(cond ((eql view :tile) `(:section :id "tiles" ,@data))
             ((eql view :card) `(:section :id "cards" ,@data))))))

(defun list-search-results-as-html (lib terms limit offset sort-by direction view)
  (check-type lib library)
  (check-type terms string)
  `(,(make-search-page-search-box lib terms)
    ,@(make-datum-listing-filter-bar view sort-by direction)
    (:small "FIXME: query should support the same kinds of filters as list-data!")
    (:section :id "tiles"
              ,@(loop for datum in (query lib terms :limit limit :offset offset)
                      collect (datum-html-preview lib datum)))))

(defun make-search-page-search-box (lib &optional terms)
  (check-type lib library)
  `(:section (:form :method "get" :action "/search"
                    (:fieldset
                     (:legend "Advanced Search")
                     (:div :class "form-row"
                           (:input :name "q" :type "text" :value ,(when terms terms)))
                     (:div :class "form-row"
                           (:small "FIXME: add tag, collection, and kind filters here; improve style!"))
                     (:input :class "form-row" :type "submit" :value "Go!")))))

(defun list-tags-as-html (lib)
  (check-type lib library)
  `((:section :id "tiles"
              ,@(loop for tag in (list-tags lib)
                      collect `(:div :class "tile"
                                     (:div :style "padding-bottom: 10px; font-size: large"
                                            (:a :href ,(format NIL "/tag?name=~A"
                                                               (url (tag-name tag)))
                                                ,(tag-name tag)))
                                     (:div ;; This'll be fun to localize lol
                                      (:span :style "white-space: nowrap"
                                             ,(format NIL "~A File~:P;"
                                                      (tag-count tag)))
                                      (:span :style "white-space: nowrap"
                                             ,(format NIL " ~A Parent~:P;"
                                                      (tag-num-parents lib tag)))
                                      (:span :style "white-space: nowrap"
                                             ,(format NIL " ~A Child~0@*~[ren~;~:;ren~]"
                                                      (tag-num-children lib tag))))
                                     ,@(let ((desc (tag-label tag)))
                                         (when desc `((:hr) ,(html desc)))))))))

(defun make-tree-breadcrumbs (title absolute-path)
  `((:h1 ,title)
    (:nav
     (:ul :class "path-breadcrumbs"
          ,@(loop with path-so-far = "/"
                  for part in (pathname-directory absolute-path)
                  when (stringp part)
                    do (setf path-so-far
                             (concatenate 'string path-so-far part "/"))
                  when (stringp part)
                    collect `(:li
                              (:a :href ,(format NIL "/tree?dir=~A"
                                                 (url path-so-far))
                                  ,part)))))))

(defun make-tree-sidebar (dir)
  `((:section
     ;; FIXME: This allows index files outside of their
     ;; $LEIBOWITZ_ROOT or $HOME, which currently causes a
     ;; no-applicable-collection error and if allowed in practice will
     ;; almost certainly be a security issue.  Really, this problem
     ;; stems from the fact that we're storing absolute paths.  When
     ;; using $LEIBOWITZ_ROOT, the library should be fully portable
     ;; with respect to the location of that directory on the
     ;; filesystem.
     ,(index-files-form dir "Index files under this directory"))
    (:section
     (:h2 "Subdirectories")
     (:ul ,@(loop for sd in (reverse (uiop:subdirectories dir))
                  for name = (car (last (pathname-directory sd)))
                  collect `(:li (:a :href ,(format NIL "/tree?dir=~A"
                                                   (url
                                                    (namestring sd)))
                                    ,(html name))))))))

(defun make-tree-unindexed-section (lib dir)
  `((:section
     (:h2 "Unindexed files")
     (:div :id "tiles"
           ,@(loop for f in (uiop:directory-files dir)
                   unless (get-datum lib f)
                     collect `(:div :class "tile"
                                    (:form :method "post" :action "/index"
                                           (:button :name "path" :value ,(html (url f))
                                                    "Index file")
                                           (:label ,(html (pathname-name
                                                           (uiop:parse-unix-namestring
                                                            f)))))))))))

(defun make-datum-view-page (lib datum)
  (incf (datum-accesses datum))
  (add-datum lib datum)
  (let ((action (format NIL "/datum?id=~A" (html (url (datum-id datum))))))
    (nconc
     (datum-html-report lib datum)
     (list
      `(:section
        (:h2 "Edit Metadata")
        (:div :id "editor-widgets-container"
              (:div :id "editor-widget-left"
                    (:fieldset
                     (:legend "Edit Tags")
                     (:form :method "post"
                            (:textarea
                             :id "tag-editor-textarea"
                             :name "tags"
                             :placeholder "No tags yet, enter each on a new line"
                             ,(with-output-to-string (s)
                                (loop for tag in (get-datum-tags lib datum)
                                      do (format s "~A~%" (tag-name tag)))))
                            (:button :id "tag-editor-submit" "Save Tags"))))
              (:div :id "editor-widget-right"
                    (:fieldset
                     (:legend "Move or Rename")
                     (:form :method "post"
                            :action ,action
                            (:input :type "text" :name "move-to"
                                    :value ,(html (datum-id datum)))
                            (:button "Move")))
                    (:fieldset
                     (:legend "Copy")
                     (:form :method "post"
                            :action ,action
                            (:input :type "text" :name "copy-to"
                                    :value ,(html (datum-id datum)))
                            (:button "Copy")))
                    (:fieldset
                     (:legend "Delete")
                     (:form :method "post"
                            :action ,action
                            (:button :name "delete"
                                     :value "yes"
                                     ,(format NIL "Permanently Delete ~A"
                                              (html (datum-title datum)))))))))
      `(:section
        (:details
         (:summary (:b "Class Description"))
         (:pre :style "overflow: scroll"
               ,(html (with-output-to-string (s)
                        ;; Tell the pretty-printer not to elide it
                        (let ((*print-right-margin* 500))
                          (describe datum s)))))))))))

(defun make-tag-view-sidebar (lib tag)
  (check-type tag tag)
  (check-type lib library)
  `((:section
     (:h2 "About")
     (:ul (:li (:span :class "sidebar-metadata-key"
                      "Count")
               (:span :class "sidebar-metadata-var"
                      ,(format NIL "~A" (tag-count tag))))
          (:li (:span :class "sidebar-metadata-key"
                      "Label")
               (:span :class "sidebar-metadata-var"
                      ,(html (tag-label tag))))))
    (:section
     (:h2 "Automatically Adds")
     (:ul ,@(loop for tag in (get-tag-predicates lib tag)
                  collect `(:li (:a :href ,(format NIL "/tag?name=~A"
                                                   (url (tag-name tag)))
                                    ,(tag-name tag))
                                (:span :class "tag-count"
                                       ,(format nil "(~a)" (tag-count tag)))))))
    (:section
     (:h2 "Automatically Added By")
     (:ul ,@(loop for tag in (get-tag-predicands lib tag)
                  collect `(:li (:a :href ,(format NIL "/tag?name=~A"
                                                   (url (tag-name tag)))
                                    ,(tag-name tag))
                                (:span :class "tag-count"
                                       ,(format nil "(~a)" (tag-count tag)))))))
    (:section
     (:h2 "Related tags")
     (:p "I'll need to figure out something clever here :p"))))

(defun make-tag-view-page (lib tag view &rest options &key &allow-other-keys)
  (check-type lib library)
  (check-type tag tag)
  `(,@(let ((data (loop for datum in (apply #'list-data (nconc (list lib) options))
                        collect (datum-html-preview lib datum :view view))))
        `(,@(make-datum-listing-filter-bar view
                                           (getf options :sort-by)
                                           (getf options :direction))
          ,(cond ((eql view :tile) `(:section :id "tiles" ,@data))
                 ((eql view :card) `(:section :id "cards" ,@data)))))
    (:section
     (:h2 "Edit Tag")
     (:div :id "editor-widgets-container"
           (:div :id "editor-widget-left"
                 (:fieldset
                  (:legend "Also Apply These Tags")
                  (:form :method "post"
                         (:textarea
                          :id "tag-editor-textarea"
                          :name "tags"
                          :placeholder "None yet, enter each on a new line"
                          ,(with-output-to-string (s)
                             (loop for tag in (get-tag-predicates lib tag)
                                   do (format s "~A~%" (tag-name tag)))))
                         (:button :id "tag-editor-submit" "Save Parent Tags"))))
           (:div :id "editor-widget-right"
                 (:fieldset
                  (:legend "Edit Tag Description")
                  (:form :method "post"
                         (:textarea :id "description-editor-textarea"
                                    :name "description"
                                    :placeholder "Something about this tag"
                                    ,(html (tag-label tag)))
                         (:button :id "description-editor-submit"
                                  "Save Description")))
                 (:fieldset
                  (:legend "Rename Tag")
                  (:form :method "post"
                         (:input :type "text" :name "move-to" :value ,(html (tag-name tag)))
                         (:button "Rename")))
                 (:fieldset
                  (:legend "Copy Tag")
                  (:form :method "post"
                         (:input :type "text" :name "copy-to" :value ,(html (tag-name tag)))
                         (:button "Copy")))
                 (:fieldset
                  (:legend "Delete Tag")
                  (:form :method "post"
                         ;; FIXME: In order to ensure atomicity,
                         ;; del-tag (or some specific recursive
                         ;; deletion method) must support this.
                         ;; (:div :class "form-row"
                         ;;       (:input :type "checkbox"
                         ;;               :name "delete-children"
                         ;;               :id "delete-children")
                         ;;       (:label :for "delete-children"
                         ;;               "Also delete child tags."))
                         ;; (:div :class "form-row"
                         ;;       (:input :type "checkbox"
                         ;;               :name "delete-data"
                         ;;               :id "delete-data")
                         ;;       (:label :for "delete-data"
                         ;;               "Also delete this tag's
                         ;;               data."))
                         (:button :name "delete"
                                  :value "yes"
                                  ,(format NIL "Permanently Delete ~S"
                                           (html (tag-name tag)))))))))))

;; FIXME: add support for filtering by tags too!  And once query has
;; been merged into list-tags, unify listing and search pages and add
;; support for filtering by query terms here!
(defun make-datum-listing-filter-bar (view sort-by direction &optional (dir NIL))
  `((:nav :id "listing-filter-controls"
          (:form :method "get" :id "datum-listing-filter-form"
                 ,(when dir `(:input :type "hidden" :name "dir" :value ,dir))
                 (:labal :for "view"
                         "View As")
                 (:select :name "view"
                          :id "view"
                          ,(if (eql view :tile)
                               `(:option :value "tile" :selected "" "Tiles")
                               `(:option :value "tile" "Tiles"))
                          ,(if (eql view :card)
                               `(:option :value "card" :selected "" "Cards")
                               `(:option :value "card" "Cards")))
                 (:label :for "sort-by"
                         "Sort by")
                 (:select :name "sort-by"
                          :id "sort-by"
                          ,(if (eql sort-by :modified)
                               `(:option :value "modified" :selected "" "Date Modified")
                               `(:option :value "modified" "Date Modified"))
                          ,(if (eql sort-by :birth)
                               `(:option :value "birth" :selected "" "Date Created")
                               `(:option :value "birth" "Date Created"))
                          ,(if (eql sort-by :accesses)
                               `(:option :value "accesses" :selected "" "Number of Views")
                               `(:option :value "accesses" "Number of Views")))
                 (:span :class "checkbox-label-container"
                        ,(if (eql direction :descending)
                             `(:input :type "radio" :name "direction" :value "descending" :checked "")
                             `(:input :type "radio" :name "direction" :value "descending"))
                        (:label :for "descending" "Descending"))
                 (:span :class "checkbox-label-container"
                        ,(if (eql direction :ascending)
                             `(:input :type "radio" :name "direction" :value "ascending" :checked "")
                             `(:input :type "radio" :name "direction" :value "ascending"))
                        (:label :for "ascending" "Ascending"))
                 (:input :type "submit" :value "Filter")))))

(defun index-files-form (path msg)
  `(:form :method "post" :action "/index" :id "index-files-form"
            (:div :class "form-row"
                  (:input :type "text" :name "path"
                          :value ,(html (namestring path))))
            (:div :class "form-row"
                  (:input :type "submit" :value ,msg)0)))
Generated 2024-06-10 19:24:14 -0700 by RepoRat