{-# LANGUAGE OverloadedStrings #-} {- Usage: ./vega-view will create web pages at http://localhost:n/ http://localhost:n/display/ where n is 8082 unless the PORT environment variable is set to an integer, in which case that will be used. The top-level page can be used to drag-and-drop specifications and view them, and supports several modes: - add to start - add to end - only show the current visualization whereas the display/ directory lets you view any Vega and Vega-Lite specfications in the working directory (or sub-directories), either "in line" (i.e. in the page) or as a separate page. The code could be refactored to be a SPA, but does it need to be? -} module Main where import qualified Data.ByteString.Lazy.Char8 as LB8 import qualified Data.HashMap.Strict as HM import qualified Data.Text as T import qualified Data.Text.Lazy as LT import qualified Text.Blaze as B import qualified Text.Blaze.Html5 as H import qualified Text.Blaze.Html5.Attributes as A import Control.Exception (IOException, try) import Control.Monad (forM_, unless) import Control.Monad.IO.Class (liftIO) import Data.Aeson (Value(String, Object), Object , (.=) , eitherDecode', encode, object) import Data.List (sort) import Data.Maybe (catMaybes) import Data.Version (showVersion) import Network.HTTP.Types (status404) import System.Directory (doesDirectoryExist, listDirectory) import System.Environment (lookupEnv) import System.FilePath ((</>), takeDirectory, takeFileName) import Text.Blaze.Html5 ((!)) import Text.Blaze.Html.Renderer.Text (renderHtml) import Web.Scotty (ScottyM, ActionM , get, html, json , notFound, param , redirect, regex , status, scotty , text) import Paths_vega_view (version) pageTitle :: H.Html pageTitle = (H.span ! A.id "title") "Vega and Vega-Lite viewer" homeLink :: H.Html homeLink = (H.a ! A.id "homeLink" ! A.href "/") "Home" toCSS, toJS :: [H.Html] -> H.Html toCSS = (H.style ! A.type_ "text/css") . mconcat toJS = (H.script ! A.type_ "text/javascript") . mconcat -- Represent a Vega or Vega-Lite sepcification, which has -- to be a Javascript object. Other than checking that we -- have an object, there is no other validation of the -- JSON. -- data Spec = Spec { specVis :: Object , specPath :: FilePath } -- Create HTML for the given specification; try to match embedSpec -- JS routines. -- createView :: Spec -- ^ This is assumed to be Vega or Vega-Lite specification, but -- no check is made. -- -- The description field is used if present. -> String -- ^ The id for the Vega-Embed visualization div. -> H.Html -- ^ The Html code needed to display this visualization -- (assumes vega-embed is already available). createView spec specId = let vis = specVis spec mDesc = case HM.lookup "description" vis of Just (String d) -> Just d _ -> Nothing jsCts = mconcat [ "const vdiv = document.getElementById('" , H.toHtml specId , "'); " , "const vopts = { downloadFileName: '" , H.toHtml (specPath spec) , "' }; " , "vegaEmbed(vdiv, " , H.toHtml (LB8.unpack (encode vis)) , ", vopts).then((result) => { " -- it's almost like I'm making this up as I go along , "resetLocationWidth(vdiv.parentElement.parentElement); " , "}).catch((err) => { " , "vdiv.appendChild(document.createTextNode(err)); " , "vdiv.setAttribute('class', 'vega-error'); " , "});" ] in (H.div ! A.class_ "vizview") $ do -- unlike embedSpec JS routines, do not add close or hide buttons (H.p ! A.class_ "location") (H.toHtml ("File: " ++ specPath spec)) (H.div ! A.class_ "contents") $ do case mDesc of Just desc -> (H.p ! A.class_ "description") (H.toHtml desc) Nothing -> pure () (H.div ! A.id (H.toValue specId)) "" (H.script ! A.type_ "text/javascript") jsCts readJSON :: FilePath -- ^ The path to the file. This *must* be relative to the -- current working directory. -> IO (Either String Value) readJSON infile = do ans <- try (LB8.readFile infile) pure $ case ans of Left e -> Left (showIOException e) Right v -> eitherDecode' v showIOException :: IOException -> String showIOException = show readSpec :: FilePath -- ^ The path to the file. This *must* be relative to the -- current working directory. -> IO (Either String Spec) readSpec infile = do cts <- either (Left . show) Right <$> readJSON infile pure $ case cts of Right (Object o) -> Right (Spec o infile) Right _ -> Left "JSON was not an object" Left e -> Left e -- | Return a HTML block (a div) that will dislay the visualization, -- if the file is a JSON object (but not guaranteed to be a Vega or -- Vega-Lite spec). The id of the visualization is based on the -- file name, so it is assumed to be unique for the page. -- makeSpec :: FilePath -> IO (Maybe H.Html) makeSpec infile = do espec <- readSpec infile case espec of Left _ -> pure Nothing Right s -> pure (Just (createView s infile)) addTextJS, addTitleJS, addDescriptionJS :: [H.Html] addTextJS = [ "function addText(parent, text) { " , "parent.appendChild(document.createTextNode(text)); " , "} " ] -- TODO: should parent node be emoved from DOM on close? depends on -- what page it is being used in addTitleJS = [ "function addTitle(div, contents, infile) { " , "const el = document.createElement('p'); " , "el.setAttribute('class', 'location'); " , "addText(el, 'File: ' + infile); " , "div.appendChild(el); " , "const close = document.createElement('span'); " , "close.setAttribute('class', 'close'); " , "el.appendChild(close); " , "close.addEventListener('click', (ev) => { " , "div.style.display = 'none'; " , "while (div.firstChild) { " , "div.removeChild(div.firstChild); " , "} " , "}); " , "const hide = document.createElement('hide'); " , "hide.setAttribute('class', 'hide'); " , "el.appendChild(hide); " , "hide.addEventListener('click', (ev) => { " , "if (contents.style.display !== 'none') { " , "contents.style.display = 'none'; " , "hide.setAttribute('class', 'show'); } else { " , "contents.style.display = 'block'; " , "hide.setAttribute('class', 'hide'); } " , "}); " , "} " ] addDescriptionJS = [ "function addDescription(div, spec) { " , "if (!spec.description || spec.description === '') { return; } " , "const el = document.createElement('p'); " , "el.setAttribute('class', 'description'); " , "addText(el, spec.description); " , "div.appendChild(el); " , "} " ] -- Do we want to hide the "swoosh" icon when a visualization is shown? -- dragJS :: H.Html dragJS = let cts = [ "function preventDefault(event) { event.preventDefault(); } " , "window.addEventListener('dragenter', preventDefault, false); " , "window.addEventListener('dragover', preventDefault); " , "window.addEventListener('drop', handleDrop); " , "function handleDrop(ev) { " , "ev.preventDefault(); " , "if (ev.dataTransfer.items) { " , "for (var i = 0; i < ev.dataTransfer.items.length; i++) { " , "if (ev.dataTransfer.items[i].kind === 'file') { " , "readFromDrop(ev.dataTransfer.items[i].getAsFile()); " , "} } } else { " , "for (var i = 0; i < ev.dataTransfer.files.length; i++) { " , "readFromDrop(ev.dataTransfer.files[i]); " , "} } " , "} " , "function readFromDrop(file) { " , "if (file.type !== 'application/json') { return; } " , "const reader = new FileReader(); " , "reader.onload = (event) => { embedSpec(file.name, event.target.result); } " , "reader.onerror = (event) => { alert('Unable to read from ' + file.name); } " , "reader.readAsText(file); " , "}" , "function embedSpec(filename, filects) { " , "let spec;" , "try { " , "spec = JSON.parse(filects); " , "} catch (error) { " , "reportParseError(filename); " , "return; " , "} " , "const parent = document.getElementById('vizlist'); " , "if (addMode === 'single') { " , "while (parent.firstChild) { " , "parent.removeChild(parent.firstChild);" , "} }" , "const div = document.createElement('div'); " , "div.setAttribute('class', 'vizview'); " , "if (addMode === 'top') { " , "parent.insertBefore(div, parent.firstChild); " , "} else { parent.appendChild(div); } " , "const contents = document.createElement('div'); " , "contents.setAttribute('class', 'contents'); " , "addTitle(div, contents, filename); " , "div.appendChild(contents); " , "addDescription(contents, spec); " , "const vdiv = document.createElement('div'); " , "contents.appendChild(vdiv); " , "const vopts = { downloadFileName: filename }; " , "vegaEmbed(vdiv, spec, vopts).catch((err) => { " , "vdiv.appendChild(document.createTextNode(err)); " , "vdiv.setAttribute('class', 'vega-error'); " , "}); " , "div.style.display = 'block';" , "} " , "var addMode = 'top'; " -- should read from HTML or set HTML , "document.getElementById('mode-select')." , "addEventListener('change', (ev) => { " , "const sel = ev.target; " , "for (var i = 0; i < sel.length; i++) { " , "if (sel[i].selected) { addMode = sel[i].value; break; } " , "} " , "}); " -- do we want to report the details of the error? -- be lazy and use an alert for now , "function reportParseError(filename) { " , "alert('Unable to parse ' + filename + ' as JSON'); " , "} " ] ++ addTextJS ++ addTitleJS ++ addDescriptionJS -- add newlines for debugging, although I've done something -- stupid to require this -- TODO track down ncts = concatMap (\n -> [n, "\n"]) cts jsCts = mconcat ncts -- jsCts = mconcat cts in (H.script ! A.type_ "text/javascript") jsCts closeCSS, hideCSS, descriptionCSS, locationCSS :: [H.Html] closeCSS = [ ".close { " , "background: rgba(230, 20, 20, 0.6); " , "border-radius: 50%; " , "cursor: pointer; " , "float: left; " , "height: 1em; " , "margin-right: 0.5em; " , "width: 1em; " , "} " , ".close:hover { " , "background: rgba(230, 20, 20, 1); " , "} " ] hideCSS = [ ".hide { " , "border-left: 0.5em solid transparent; " , "border-right: 0.5em solid transparent; " , "border-top: 1em solid rgba(255, 165, 0, 0.6); " , "cursor: pointer; " , "float: left; " , "height: 0; " , "margin-right: 0.5em; " , "width: 0; " , "} " , ".show { " , "border-left: 0.5em solid transparent; " , "border-right: 0.5em solid transparent; " , "border-bottom: 1em solid rgba(255, 165, 0, 0.6); " , "cursor: pointer; " , "float: left; " , "height: 0; " , "margin-right: 0.5em; " , "width: 0; " , "} " , ".hide:hover { " , "border-top: 1em solid rgba(255, 165, 0, 1); " , "} " , ".show:hover { " , "border-bottom: 1em solid rgba(255, 165, 0, 1); " , "} " ] descriptionCSS = [ "p.description { " , "text-align: center; " , "}" ] -- combine location and contents here -- locationCSS = [ "p.location { " , "background: rgba(0, 0, 0, 0.2);" , "font-weight: bold; " , "margin: -1em; " , "margin-bottom: 0; " , "padding: 0.5em; " , "} " , "div.contents { " , "margin: 0; " , "margin-top: 1em; " , "} " ] pageSetupCSS :: [H.Html] pageSetupCSS = [ "body { margin: 0; } " , "#infobar { " , "background: rgb(120, 120, 200); " , "color: white; " , "font-family: sans-serif; " , "padding: 0.5em; " , "} " , "#infobar #title { " , "font-size: 150%; " , "font-variant-caps: small-caps; " , "margin-right: 2em; " , "} " , "#infobar #homeLink { " , "color: white; " , "text-decoration: none; " , "} " , "#homeLink:hover { " , "border-bottom: 2px solid white; " , "} " , "#mainbar { " , "padding: 1em; " , "} " ] -- not convinced using the header color is a good thing to indicate -- an error; should be visually distinct -- vegaErrorCSS :: [H.Html] vegaErrorCSS = [ ".vega-error { " , "background: rgba(120, 120, 200); " , "color: white; " , "font-family: monospace; " , "font-size: 150%; " , "font-weight: bold; " , "padding: 0.5em; " , "} " ] vizCSS :: [H.Html] vizCSS = [ ".vizview { " , "background-color: white; " , "border: 2px solid rgba(0, 0, 0, 0.4); " , "border-radius: 0.5em; " , "padding: 1em; " , "} " , ".vizview:hover { " , "border-color: rgba(0, 0, 0, 0.8); " , "box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.2); " , "} " ] -- Handle header / main areas of the page sectionsCSS :: [H.Html] sectionsCSS = [ "#infobar label { " , "margin-right: 0.5em; " , "}" , "#mainbar { " , "padding: 1em; " , "} " , "#mainbar #swoosh svg { " , "fill: rgba(120, 120, 200, 0.2); " , "height: 200px; " , "width: 200px; " , "} " ] dragCSS :: H.Html dragCSS = let cts = pageSetupCSS ++ [ ".vizview { " , "float: left; " , "margin: 0.5em; " , "} " ] ++ closeCSS ++ hideCSS ++ descriptionCSS ++ locationCSS ++ vegaErrorCSS ++ sectionsCSS ++ vizCSS in toCSS cts indexPage :: H.Html indexPage = H.docTypeHtml ! A.lang "en-US" $ do H.head $ do H.title "View a Vega or Vega-Lite specification" vegaEmbed dragCSS H.body $ do (H.div ! A.id "infobar") $ do pageTitle (H.label ! A.for "mode-select") "Drop mode:" (H.select ! A.id "mode-select") $ do (H.option ! A.value "single") "Single" -- TODO: can get selected="" with this, but not selected as a -- stand-alone attribute (H.option ! A.value "top" ! A.selected "") "Add at start" (H.option ! A.value "bottom") "Add to end" let elink url = H.a ! A.href url ! A.target "_blank" (H.div ! A.id "mainbar") $ do H.p (mconcat [ "This is version " , H.toHtml (showVersion version) , " of " , elink "https://github.com/DougBurke/vega-view#readme" "vega-view" , ". Go to " , (H.a ! A.href "/display/") "/display/" , " to see the available visualizations, or " , "drag files containing " , elink "https://vega.github.io/vega-lite/" "Vega" , " or " , elink "https://vega.github.io/vega-lite/" "Vega-Lite" , " visualizations onto this page to view them." ]) (H.div ! A.id "vizlist") "" -- embed the SVG directly so we can style it (H.div ! A.id "swoosh") (B.preEscapedText swooshSVG) -- since too lazy to set up an onload handler, stick all the JS -- here dragJS -- Return the directories in ths directory, and the JSON files we -- can try displaying. All other files are dropped. -- getFileContents :: FilePath -> IO ([FilePath], [(FilePath, H.Html)]) -- ^ First we list the directories in ths directory, and then the -- displayable contents. Either list can be empty. -- getFileContents indir = do infiles <- map (indir </>) . sort <$> listDirectory indir dirFlags <- mapM doesDirectoryExist infiles let files = zip dirFlags infiles -- these are not expected to be large lists so any duplicated effort -- is not large; also, rely on the power of the compiler to fuse -- everything -- dirNames = map snd (filter fst files) otherNames = map snd (filter (not . fst) files) go f = do mspec <- makeSpec f case mspec of Just h -> pure (Just (f, h)) _ -> pure Nothing mspecs <- mapM go otherNames let specs = catMaybes mspecs pure (dirNames, specs) pageLink :: FilePath -> H.Html pageLink infile = let toHref = H.toValue ("/display" </> infile) toText = H.toHtml (takeFileName infile) in (H.a ! A.href toHref) toText makeLi :: FilePath -> H.Html makeLi infile = H.li (pageLink infile) makeParentLink :: FilePath -> H.Html makeParentLink indir = let toHref = H.toValue ("/display" </> indir </> "..") in (H.a ! A.href toHref) "parent directory" embedLink :: FilePath -> H.Html embedLink infile = let toHref = H.toValue ("/embed" </> infile) toText = H.toHtml (takeFileName infile) hdlr = mconcat [ "embed('", toHref, "');" ] in (H.a ! A.href "#" ! A.onclick hdlr) toText -- Nothing to see here; slightly different if base directory or not emptyDir :: FilePath -> ActionM () emptyDir indir = let page = (H.docTypeHtml ! A.lang "en-US") $ do H.head $ do H.title (H.toHtml ("Files to view: " ++ indir)) toCSS pageSetupCSS H.body $ do (H.div ! A.id "infobar") $ do pageTitle homeLink (H.div ! A.id "mainbar") $ if indir == "." then H.p "There is nothing to see in the base directory!" else do H.p (H.toHtml ("Directory: " ++ indir)) H.p (makeParentLink indir) H.p "There is nothing to see here!" in html (renderHtml page) -- Code to display a specification inline -- -- Would be a lot nicer to embed the JS code from a file at build time -- or to load at run time. -- -- TODO: set max width/height of the visualization window so that -- overflow works? Not obvious best way to do this. -- -- TODO: allow the user to drag the window around -- embedJS :: H.Html embedJS = let cts = [ "function embed(path) { " , "var req = new XMLHttpRequest(); " , "req.addEventListener('load', embedSpec); " , "req.responseType = 'json'; " , "req.open('GET', path); " , "req.send(); " , "} " , "function embedSpec(e) { " , "const div = document.getElementById('vizview'); " , "while (div.firstChild) { " , "div.removeChild(div.firstChild);" , "} " , "const tgt = e.target; " , "if (tgt.status == 200) { " , "const vopts = { downloadFileName: tgt.response.infile }; " , "const contents = document.createElement('div'); " , "contents.setAttribute('class', 'contents'); " , "addTitle(div, contents, tgt.response.infile); " , "div.appendChild(contents); " , "addDescription(contents, tgt.response.spec); " , "const vdiv = document.createElement('div'); " , "contents.appendChild(vdiv); " , "vegaEmbed(vdiv, tgt.response.spec, vopts).catch((err) => { " , "vdiv.appendChild(document.createTextNode(err)); " , "vdiv.setAttribute('class', 'vega-error'); " , "}); " , "} else { " , "addText(div, 'Unable to load specification'); " , "} " , "div.style.display = 'block';" , "} " ] ++ addTextJS ++ addTitleJS ++ addDescriptionJS in (H.script ! A.type_ "text/javascript") (mconcat cts) embedCSS :: H.Html embedCSS = let cts = [ ".vizview { " , "display: none; " , "left: 2em; " -- , "overflow: hidden; " why did I add this? , "position: fixed; " , "top: 2em; " , "} " ] ++ closeCSS ++ hideCSS ++ descriptionCSS ++ locationCSS ++ pageSetupCSS ++ vegaErrorCSS ++ vizCSS in toCSS cts showDir :: FilePath -> ([FilePath], [(FilePath, H.Html)]) -> ActionM () showDir indir (subdirs, files) = let atTop = indir == "." page = (H.docTypeHtml ! A.lang "en-US") $ do H.head $ do H.title (H.toHtml ("Files to view: " ++ indir)) unless (null files) $ do vegaEmbed embedJS embedCSS H.body $ do (H.div ! A.id "infobar") $ do pageTitle homeLink (H.div ! A.id "mainbar") $ do unless atTop $ do H.p (H.toHtml ("Directory: " ++ indir)) H.p (makeParentLink indir) unless (null subdirs) $ do H.h2 "Sub-directories" H.ul (forM_ subdirs makeLi) -- let's see how this basic setup works -- -- TODO: might be nice to let users easily skip to next or -- previous visualization when viewing one. -- unless (null files) $ do (H.div ! A.class_ "vizview" ! A.id "vizview") "" (H.div ! A.id "vizlist") $ do H.h2 "Visualizations" H.table $ do H.thead $ H.tr $ do H.th "View page" H.th "View inline" H.tbody $ forM_ files $ \(f, _) -> H.tr $ do H.td (pageLink f) H.td (embedLink f) in html (renderHtml page) dirPage :: FilePath -> ActionM () dirPage indir = do files <- liftIO (getFileContents indir) case files of ([], []) -> emptyDir indir _ -> showDir indir files -- load up vega embed vegaEmbed :: H.Html vegaEmbed = let load n = H.script ! A.src (mconcat [ "https://cdn.jsdelivr.net/npm/" , n]) in do load "vega@5" "" load "vega-lite@3" "" load "vega-embed@4" "" {- try align-items or align-self from https://stackoverflow.com/questions/40141163/make-flex-items-take-content-width-not-width-of-parent-container want to get title of block the full container width, and also get things working for inline/drag+drop how about adding a resize handler on the window to adjust all the vizview.location widths, and then trigger this on creation too -} pageCSS :: H.Html pageCSS = let cts = pageSetupCSS ++ [ ".vizview { " , "overflow: auto; " , "} " , ".vizlist { " , "display: flex; " , "justify-content: space-around; " -- not convinced about this , "} " ] ++ descriptionCSS ++ locationCSS ++ vegaErrorCSS ++ sectionsCSS ++ vizCSS in toCSS cts -- change the "title" bar, containing the loction, but not any description, -- as want that to stay bouded by the starting bounding box, I think -- (so that it doesn't appear off-screen initially for a short-enough -- description, if centered). -- -- I had originally thought I would have to call resetLocationWidth on -- a page resize, but it doesn't need to be, since the title is never -- going to need to be larger than the value the scrollWidth of the -- visualization. -- pageJS :: H.Html pageJS = let cts = [ "function resetLocationWidth(div) { " , "const locs = div.getElementsByClassName('location'); " , "if (locs.length === 0) { console.log('DBG: no location'); console.log({div}); return; } " , "const loc = locs[0]; " , "loc.style.width = div.scrollWidth + 'px'; " , "} " ] in toJS cts showPage :: FilePath -> ActionM () showPage infile = do espec <- liftIO (readSpec infile) case espec of Left emsg -> do -- This is not very informative, but at least provides the user -- with some information. The assumption is that this is running -- "locally" so we do not have to worry about any possible -- information leak from this. -- text (LT.pack emsg) errorStatus Right spec -> let contents = createView spec "vega-vis" page = (H.docTypeHtml ! A.lang "en-US") $ do H.head $ do H.title "View a spec" vegaEmbed pageCSS pageJS H.body $ do (H.div ! A.id "infobar") $ do pageTitle homeLink (H.div ! A.id "mainbar") $ do H.p $ H.toHtml (mconcat ["Go to ", parentLink]) (H.div ! A.class_ "vizlist") contents dirName = H.toValue ("/display" </> takeDirectory infile) parentLink = (H.a ! A.href dirName) "parent directory" in html (renderHtml page) displayPage :: FilePath -> ActionM () displayPage infile = do isDir <- liftIO (doesDirectoryExist infile) if isDir then dirPage infile else showPage infile -- Return data needed to display this file. -- embedPage :: FilePath -> ActionM () embedPage infile = do espec <- liftIO (readSpec infile) case espec of Right (Spec o _) -> json (object [ "spec" .= Object o , "infile" .= infile ]) _ -> errorStatus -- embed https://commons.wikimedia.org/wiki/File:Curved_Arrow.svg -- which is licensed under the Creative Commons CC0 1.0 Universal -- Public Domain Dedication -- swooshSVG :: T.Text swooshSVG = mconcat [ "<svg" , " xmlns:dc=\"http://purl.org/dc/elements/1.1/\"" , " xmlns:cc=\"http://creativecommons.org/ns#\"" , " xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"" , " xmlns:svg=\"http://www.w3.org/2000/svg\"" , " xmlns=\"http://www.w3.org/2000/svg\"" , " xml:space=\"preserve\"" , " enable-background=\"new 0 0 595.28 841.89\"" , " viewBox=\"0 0 776.09175 693.66538\"" , " height=\"693.66541\"" , " width=\"776.0918\"" , " y=\"0px\"" , " x=\"0px\"" , " id=\"Layer_1\"" , " version=\"1.1\"><metadata" , " id=\"metadata11\"><rdf:RDF><cc:Work" , " rdf:about=\"\"><dc:format>image/svg+xml</dc:format><dc:type" , " rdf:resource=\"http://purl.org/dc/dcmitype/StillImage\" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs" , " id=\"defs9\" /><g" , " transform=\"matrix(2.7190747,0,0,3.1037754,-326.9763,-1172.9045)\"" , " id=\"g3\"><path" , " style=\"clip-rule:evenodd;fill-rule:evenodd\"" , " id=\"path5\"" , " d=\"m 130.838,381.118 c 1.125,28.749 5.277,54.82 12.695,78.018 7.205,22.53 18.847,40.222 36.812,53.747 52.018,39.16 153.369,16.572 153.369,16.572 l -4.632,-32.843 72.918,42.778 -58.597,58.775 -3.85,-27.303 c 0,0 -100.347,18.529 -163.905,-34.881 -37.659,-31.646 -53.293,-84.021 -51.593,-153.962 0.266,-0.247 4.728,-0.908 6.783,-0.901 z\" /></g></svg>" ] errorStatus :: ActionM () errorStatus = status status404 webapp :: ScottyM () webapp = do get "/" (redirect "/index.html") get "/index.html" (html (renderHtml indexPage)) -- TODO: catch errors get "/display/" (dirPage ".") get (regex "^/display/(.+)$") $ do infile <- param "1" displayPage infile get (regex "^/embed/(.+)$") $ do infile <- param "1" embedPage infile notFound errorStatus -- for now assume current directory main :: IO () main = do mPortStr <- lookupEnv "PORT" let port = case read <$> mPortStr of Just n -> n _ -> 8082 scotty port webapp