packages feed

neuron-0.4.0.0: src/app/Neuron/Web/View.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE Rank2Types #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- | HTML & CSS
module Neuron.Web.View
  ( renderRouteHead,
    renderRouteBody,
    style,
  )
where

import Clay hiding (id, ms, object, reverse, s, style, type_)
import qualified Clay as C
import Data.Aeson ((.=), object)
import qualified Data.Aeson.Text as Aeson
import Data.Default (def)
import Data.FileEmbed (embedStringFile)
import Data.Foldable (maximum)
import qualified Data.Set as Set
import Data.Structured.Breadcrumb (Breadcrumb)
import qualified Data.Structured.Breadcrumb as Breadcrumb
import Data.TagTree (Tag (..))
import Data.Tree (Tree (..))
import Lucid
import Neuron.Config
import Neuron.Version (neuronVersionFull)
import Neuron.Web.Route
import qualified Neuron.Web.Theme as Theme
import Neuron.Zettelkasten.Connection
import qualified Neuron.Zettelkasten.Graph as G
import Neuron.Zettelkasten.Graph (ZettelGraph)
import Neuron.Zettelkasten.ID (ZettelID (..), zettelIDSourceFileName, zettelIDText)
import Neuron.Zettelkasten.Query.View (renderZettelLink)
import Neuron.Zettelkasten.Zettel
import Relude
import qualified Rib
import Rib.Extra.CSS (mozillaKbdStyle)
import qualified Rib.Parser.MMark as MMark
import Text.MMark (Extension, useExtensions)
import Text.Pandoc.Highlighting (styleToCss, tango)
import Text.URI.QQ

searchScript :: Text
searchScript = $(embedStringFile "./src-js/search.js")

renderRouteHead :: Monad m => Config -> Route graph a -> (graph, a) -> HtmlT m ()
renderRouteHead config r val = do
  meta_ [httpEquiv_ "Content-Type", content_ "text/html; charset=utf-8"]
  meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1"]
  title_ $ toHtml $ routeTitle config (snd val) r
  link_ [rel_ "shortcut icon", href_ "https://raw.githubusercontent.com/srid/neuron/master/assets/logo.ico"]
  case r of
    Route_Redirect _ ->
      mempty
    Route_Search {} -> do
      with (script_ mempty) [src_ "https://cdn.jsdelivr.net/npm/jquery@3.5.0/dist/jquery.min.js"]
      with (script_ mempty) [src_ "https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"]
      with (script_ mempty) [src_ "https://cdn.jsdelivr.net/npm/js-search@2.0.0/dist/umd/js-search.min.js"]
    _ -> do
      toHtml $ routeOpenGraph config (snd val) r
      toHtml $ routeStructuredData config val r
      style_ [type_ "text/css"] $ styleToCss tango
  where
    routeStructuredData :: Config -> (g, a) -> Route g a -> [Breadcrumb]
    routeStructuredData Config {..} (graph, v) = \case
      Route_Zettel _ ->
        case siteBaseUrl of
          Nothing -> []
          Just baseUrl ->
            let mkCrumb :: Zettel -> Breadcrumb.Item
                mkCrumb Zettel {..} =
                  Breadcrumb.Item zettelTitle (Just $ routeUri baseUrl $ Route_Zettel zettelID)
             in Breadcrumb.fromForest $ fmap mkCrumb <$> G.backlinkForest Folgezettel (fst v) graph
      _ ->
        []

renderRouteBody :: Monad m => Config -> Route graph a -> (graph, a) -> HtmlT m ()
renderRouteBody config r (g, x) = do
  case r of
    Route_ZIndex ->
      renderIndex config g
    Route_Search {} ->
      renderSearch g
    Route_Zettel zid ->
      renderZettel config (g, x) zid
    Route_Redirect _ ->
      meta_ [httpEquiv_ "Refresh", content_ $ "0; url=" <> (Rib.routeUrlRel $ Route_Zettel x)]

renderIndex :: Monad m => Config -> ZettelGraph -> HtmlT m ()
renderIndex Config {..} graph = do
  let neuronTheme = Theme.mkTheme theme
  h1_ [class_ "header"] $ "Zettel Index"
  div_ [class_ "z-index"] $ do
    -- Cycle detection.
    case G.topSort graph of
      Left (toList -> cyc) -> div_ [class_ "ui orange segment"] $ do
        h2_ "Cycle detected"
        forM_ cyc $ \zettel ->
          li_ $ renderZettelLink def zettel
      _ -> mempty
    let clusters = G.categoryClusters graph
    p_ $ do
      "There " <> countNounBe "cluster" "clusters" (length clusters) <> " in the Zettelkasten graph. "
      "Each cluster is rendered as a forest, with their roots (mother zettels) highlighted."
    forM_ clusters $ \forest ->
      div_ [class_ $ "ui stacked " <> Theme.semanticColor neuronTheme <> " segment"] $ do
        -- Forest of zettels, beginning with mother vertices.
        ul_ $ renderForest True Nothing True graph forest
    renderBrandFooter True
  where
    countNounBe noun nounPlural = \case
      1 -> "is 1 " <> noun
      n -> "are " <> show n <> " " <> nounPlural

renderSearch :: forall m. Monad m => ZettelGraph -> HtmlT m ()
renderSearch graph = do
  h1_ [class_ "header"] $ "Search"
  div_ [class_ "ui fluid icon input search"] $ do
    input_ [type_ "text", id_ "search-input"]
    fa "search icon fas fa-search"
  div_ [class_ "ui hidden divider"] mempty
  let allZettels = G.getZettels graph
      allTags = Set.fromList $ concatMap zettelTags allZettels
      index = object ["zettels" .= fmap (object . zettelJson) allZettels, "tags" .= allTags]
  div_ [class_ "ui fluid multiple search selection dropdown", id_ "search-tags"] $ do
    with (input_ mempty) [name_ "tags", type_ "hidden"]
    with (i_ mempty) [class_ "dropdown icon"]
    div_ [class_ "default text"] "Select tags…"
    div_ [class_ "menu"] $ do
      forM_ allTags $ \tag -> do
        div_ [class_ "item"] $ toHtml (unTag tag)
  div_ [class_ "ui divider"] mempty
  ul_ [id_ "search-results", class_ "zettel-list"] mempty
  script_ $ "let index = " <> toText (Aeson.encodeToLazyText index) <> ";"
  script_ searchScript

renderZettel :: forall m. Monad m => Config -> (ZettelGraph, (Zettel, Extension)) -> ZettelID -> HtmlT m ()
renderZettel config@Config {..} (graph, (z@Zettel {..}, ext)) zid = do
  let neuronTheme = Theme.mkTheme theme
  div_ [class_ "zettel-view"] $ do
    div_ [class_ "ui raised segments"] $ do
      div_ [class_ "ui top attached segment"] $ do
        h1_ [class_ "header"] $ toHtml zettelTitle
        let mmarkExts = getMarkdownExtensions config
        MMark.render $ useExtensions (ext : mmarkExts) zettelContent
        whenNotNull zettelTags $ \_ ->
          renderTags zettelTags
        forM_ zettelDay $ \day ->
          div_ [class_ "date", title_ "Zettel creation date"] $ toHtml $ show @Text day
    div_ [class_ $ "ui inverted " <> Theme.semanticColor neuronTheme <> " top attached connections segment"] $ do
      div_ [class_ "ui two column grid"] $ do
        div_ [class_ "column"] $ do
          div_ [class_ "ui header"] "Down"
          ul_ $ renderForest True (Just 2) False graph $
            G.frontlinkForest Folgezettel z graph
        div_ [class_ "column"] $ do
          div_ [class_ "ui header"] "Up"
          ul_ $ do
            renderForest True Nothing False graph $
              G.backlinkForest Folgezettel z graph
          div_ [class_ "ui header"] "Other backlinks"
          ul_ $ do
            renderForest True Nothing False graph
              $ fmap (flip Node [])
              $ G.backlinks OrdinaryConnection z graph
    div_ [class_ "ui inverted black bottom attached footer segment"] $ do
      div_ [class_ "ui equal width grid"] $ do
        div_ [class_ "center aligned column"] $ do
          let homeUrl = maybe "." (const "index.html") $ G.getZettel (ZettelCustomID "index") graph
          a_ [href_ homeUrl, title_ "/"] $ fa "fas fa-home"
        whenJust editUrl $ \urlPrefix ->
          div_ [class_ "center aligned column"] $ do
            a_ [href_ $ urlPrefix <> toText (zettelIDSourceFileName zid), title_ "Edit this Zettel"] $ fa "fas fa-edit"
        div_ [class_ "center aligned column"] $ do
          a_ [href_ (Rib.routeUrlRel Route_Search), title_ "Search Zettels"] $ fa "fas fa-search"
        div_ [class_ "center aligned column"] $ do
          a_ [href_ (Rib.routeUrlRel Route_ZIndex), title_ "All Zettels (z-index)"] $
            fa "fas fa-tree"
    renderBrandFooter False

renderBrandFooter :: Monad m => Bool -> HtmlT m ()
renderBrandFooter withVersion =
  div_ [class_ "ui one column grid footer-version"] $ do
    div_ [class_ "center aligned column"] $ do
      p_ $ do
        "Generated by "
        a_ [href_ "https://github.com/srid/neuron"] "Neuron"
        when withVersion $ do
          " "
          code_ $ toHtml @Text neuronVersionFull

renderTags :: Monad m => [Tag] -> HtmlT m ()
renderTags tags = do
  forM_ tags $ \(unTag -> tag) -> do
    -- TODO: Ideally this should be at the top, not bottom. But putting it at
    -- the top pushes the zettel content down, introducing unnecessary white
    -- space below the title. So we put it at the bottom for now.
    span_ [class_ "ui black right ribbon label", title_ "Tag"] $ do
      a_
        [ href_ $ routeUrlRelWithQuery Route_Search [queryKey|tag|] tag,
          title_ ("See all zettels tagged '" <> tag <> "'")
        ]
        $ toHtml tag
    p_ mempty

-- | Font awesome element
fa :: Monad m => Text -> HtmlT m ()
fa k = with i_ [class_ k] mempty

renderForest ::
  Monad m =>
  Bool ->
  Maybe Int ->
  Bool ->
  ZettelGraph ->
  [Tree Zettel] ->
  HtmlT m ()
renderForest isRoot maxLevel renderingFullTree g trees =
  case maxLevel of
    Just 0 -> mempty
    _ -> do
      forM_ (sortForest trees) $ \(Node zettel subtrees) ->
        li_ $ do
          let zettelDiv =
                div_
                  [class_ $ bool "" "ui black label" renderingFullTree]
          bool id zettelDiv isRoot $
            renderZettelLink def zettel
          when renderingFullTree $ do
            " "
            case G.backlinks Folgezettel zettel g of
              conns@(_ : _ : _) ->
                -- Has two or more category backlinks
                forM_ conns $ \zettel2 -> do
                  i_ [class_ "fas fa-link", title_ $ zettelIDText (zettelID zettel2) <> " " <> zettelTitle zettel2] mempty
              _ -> mempty
          when (length subtrees > 0) $ do
            ul_ $ renderForest False ((\n -> n - 1) <$> maxLevel) renderingFullTree g subtrees
  where
    -- Sort trees so that trees containing the most recent zettel (by ID) come first.
    sortForest = reverse . sortOn maximum

style :: Config -> Css
style Config {..} = do
  let neuronTheme = Theme.mkTheme theme
      linkColor = Theme.withRgb neuronTheme C.rgb
  "span.zettel-link-container span.zettel-link a" ? do
    C.fontWeight C.bold
    C.color linkColor
    C.textDecoration C.none
  "span.zettel-link-container span.zettel-link a:hover" ? do
    C.backgroundColor linkColor
    C.color C.white
  "span.zettel-link-container span.extra" ? do
    C.color C.auto
    C.paddingRight $ em 0.3
  "div.z-index" ? do
    C.ul ? do
      C.listStyleType C.square
      C.paddingLeft $ em 1.5
  "div.zettel-view" ? do
    "div.date" ? do
      C.textAlign C.center
      C.color C.gray
    C.ul ? do
      C.paddingLeft $ em 1.5
      C.listStyleType C.square
      C.li ? do
        mempty -- C.paddingBottom $ em 1
    C.h1 ? do
      C.paddingTop $ em 0.2
      C.paddingBottom $ em 0.2
      C.textAlign C.center
      C.fontWeight $ weight 700
      C.backgroundColor $ Theme.withRgb neuronTheme C.rgba 0.1
    C.h2 ? do
      C.fontWeight $ weight 600
      C.borderBottom C.solid (px 1) C.steelblue
      C.marginBottom $ em 0.5
    C.h3 ? do
      C.fontWeight $ weight 400
      C.margin (px 0) (px 0) (em 0.4) (px 0)
    C.h4 ? do
      C.fontWeight $ weight 300
      C.opacity 0.8
    codeStyle
    blockquoteStyle
    kbd ? mozillaKbdStyle
  "div.tag-tree" ? do
    "div.node" ? do
      C.fontWeight C.bold
      "a.inactive" ? do
        C.color "#555"
  "div.connections" ? do
    "a" ? do
      C.important $ color white
    "a:hover" ? do
      C.opacity 0.5
  ".footer" ? do
    "a" ? do
      C.color white
  ".footer-version, .footer-version a, .footer-version a:visited" ? do
    C.color gray
  ".footer-version a" ? do
    C.fontWeight C.bold
  ".footer-version" ? do
    C.fontSize $ em 0.7
  "[data-tooltip]:after" ? do
    C.fontSize $ em 0.7
  where
    codeStyle = do
      C.code ? do
        sym margin auto
        fontSize $ pct 90
      "code, pre, tt" ? do
        fontFamily ["SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New"] [monospace]
      pre ? do
        sym padding $ em 0.5
        C.overflow auto
        C.maxWidth $ pct 100
      "div.source-code" ? do
        marginLeft auto
        marginRight auto
        pre ? do
          backgroundColor "#f8f8f8"
    -- https://css-tricks.com/snippets/css/simple-and-nice-blockquote-styling/
    blockquoteStyle =
      C.blockquote ? do
        C.backgroundColor "#f9f9f9"
        C.borderLeft C.solid (px 10) "#ccc"
        sym2 C.margin (em 1.5) (px 0)
        sym2 C.padding (em 0.5) (px 10)