Initial state
Getty Ritter
12 years ago
| 1 | Name: khuzd | |
| 2 | Version: 0.1 | |
| 3 | Synopsis: Project Synopsis Here | |
| 4 | Description: Project Description Here | |
| 5 | License: AllRightsReserved | |
| 6 | Author: Author | |
| 7 | Maintainer: maintainer@example.com | |
| 8 | Stability: Experimental | |
| 9 | Category: Web | |
| 10 | Build-type: Simple | |
| 11 | Cabal-version: >=1.2 | |
| 12 | ||
| 13 | Executable khuzd | |
| 14 | hs-source-dirs: src | |
| 15 | main-is: Main.hs | |
| 16 | ||
| 17 | Build-depends: | |
| 18 | base >= 4 && < 5, | |
| 19 | bytestring >= 0.9.1 && < 0.11, | |
| 20 | MonadCatchIO-transformers >= 0.2.1 && < 0.4, | |
| 21 | mtl >= 2 && < 3, | |
| 22 | snap-core >= 0.9 && < 0.11, | |
| 23 | snap-server >= 0.9 && < 0.11, | |
| 24 | snap, | |
| 25 | lens, | |
| 26 | blaze-html, | |
| 27 | blaze-markup, | |
| 28 | transformers, | |
| 29 | pandoc, | |
| 30 | time, | |
| 31 | sqlite-simple, | |
| 32 | snaplet-sqlite-simple, | |
| 33 | data-default, | |
| 34 | text, | |
| 35 | old-locale | |
| 36 | ||
| 37 | if impl(ghc >= 6.12.0) | |
| 38 | ghc-options: -threaded -Wall -fwarn-tabs -funbox-strict-fields -O2 | |
| 39 | -fno-warn-unused-do-bind | |
| 40 | else | |
| 41 | ghc-options: -threaded -Wall -fwarn-tabs -funbox-strict-fields -O2 |
| 1 | CREATE TABLE posts | |
| 2 | ( id integer primary key autoincrement | |
| 3 | , title text not null | |
| 4 | , contents text not null | |
| 5 | , author text not null | |
| 6 | , time integer not null | |
| 7 | , next integer | |
| 8 | , prev integer | |
| 9 | ); | |
| 10 | ||
| 11 | CREATE TABLE lookup | |
| 12 | ( id integer primary key autoincrement | |
| 13 | , year integer | |
| 14 | , month integer | |
| 15 | , time integer | |
| 16 | , slug text | |
| 17 | , post_id integer references posts(id) | |
| 18 | ); |
| 1 | {-# LANGUAGE TemplateHaskell, OverloadedStrings, FlexibleInstances #-} | |
| 2 | ||
| 3 | module Application where | |
| 4 | ||
| 5 | import Control.Lens | |
| 6 | import Control.Monad.State (get) | |
| 7 | import Snap.Snaplet | |
| 8 | import Snap.Snaplet.Auth | |
| 9 | import Snap.Snaplet.Session | |
| 10 | import Snap.Snaplet.SqliteSimple | |
| 11 | ||
| 12 | data App = App | |
| 13 | { _sess :: Snaplet SessionManager | |
| 14 | , _db :: Snaplet Sqlite | |
| 15 | , _auth :: Snaplet (AuthManager App) | |
| 16 | } | |
| 17 | ||
| 18 | makeLenses ''App | |
| 19 | ||
| 20 | type AppHandler = Handler App App | |
| 21 | ||
| 22 | instance HasSqlite (Handler App App) where | |
| 23 | getSqliteState = with db get |
| 1 | {-# LANGUAGE OverloadedStrings, RecordWildCards #-} | |
| 2 | ||
| 3 | module Main where | |
| 4 | ||
| 5 | import Application | |
| 6 | import Templates | |
| 7 | import Types | |
| 8 | ||
| 9 | import Control.Applicative ((<|>)) | |
| 10 | import Control.Monad.IO.Class (liftIO) | |
| 11 | import Data.ByteString.Char8 (ByteString, readInt, unpack) | |
| 12 | import Data.Default (def) | |
| 13 | import qualified Data.Text as T | |
| 14 | import Data.Text.Encoding (encodeUtf8, decodeUtf8) | |
| 15 | import Snap.Core | |
| 16 | import Snap.Util.FileServe | |
| 17 | import Snap.Http.Server | |
| 18 | import Snap.Snaplet | |
| 19 | import Snap.Snaplet.Auth | |
| 20 | import Snap.Snaplet.Auth.Backends.SqliteSimple | |
| 21 | import Snap.Snaplet.Session.Backends.CookieSession | |
| 22 | import Snap.Snaplet.SqliteSimple | |
| 23 | import Text.Blaze (toMarkup) | |
| 24 | import Text.Blaze.Html.Renderer.Utf8 (renderHtmlBuilder) | |
| 25 | ||
| 26 | ||
| 27 | basicError :: Response | |
| 28 | basicError = setResponseCode 400 emptyResponse | |
| 29 | ||
| 30 | main :: IO () | |
| 31 | main = do | |
| 32 | (_, s, _) <- runSnaplet Nothing app | |
| 33 | quickHttpServe s | |
| 34 | ||
| 35 | handleLogin :: Maybe T.Text -> Handler App (AuthManager App) () | |
| 36 | handleLogin _ = writeBuilder (renderHtmlBuilder (errorPage err msg)) | |
| 37 | where err = "Authentication failed!" | |
| 38 | msg = "Unknown user or password" | |
| 39 | ||
| 40 | handleLoginSubmit :: Handler App (AuthManager App) () | |
| 41 | handleLoginSubmit = loginUser "user" "passwd" Nothing (\_ -> handleLogin err) (redirect "/") | |
| 42 | where err = Just "Unknown user or password" | |
| 43 | ||
| 44 | handleLogout :: Handler App (AuthManager App) () | |
| 45 | handleLogout = logout >> redirect "/" | |
| 46 | ||
| 47 | app :: SnapletInit App App | |
| 48 | app = makeSnaplet "khuzd" "Strike the earth!" Nothing $ do | |
| 49 | s <- nestSnaplet "sess" sess $ initCookieSessionManager "site_key.txt" "sess" (Just 3600) | |
| 50 | d <- nestSnaplet "db" db sqliteInit | |
| 51 | a <- nestSnaplet "auth" auth $ initSqliteAuth sess d | |
| 52 | addRoutes routes | |
| 53 | return $ App s d a | |
| 54 | ||
| 55 | routes :: [(ByteString, AppHandler ())] | |
| 56 | routes = [ ("/", method POST doAddPost <|> | |
| 57 | (doIndex >>= doPage)) | |
| 58 | , ("/auth", doLoginForm >>= doPage) | |
| 59 | , ("/archive", doArchive >>= doPage) | |
| 60 | , ("/create", doCreate >>= doPage) | |
| 61 | , ("/:year/:month/:slug", doPost >>= doPage) | |
| 62 | , ("/:year/:month/:slug/edit", editPost >>= doPage) | |
| 63 | , ("/login", with auth handleLoginSubmit) | |
| 64 | , ("/logout", with auth handleLogout) | |
| 65 | , ("/newest", doNewestRedirect) | |
| 66 | , ("/oldest", doOldestRedirect) | |
| 67 | , ("/change", method POST doPasswdChange <|> | |
| 68 | (doPasswdForm >>= doPage)) | |
| 69 | , ("/static", serveDirectory "static") | |
| 70 | , ("/newuser", method POST doNewUser) | |
| 71 | ] | |
| 72 | ||
| 73 | doPasswdForm :: AppHandler Page | |
| 74 | doPasswdForm = do | |
| 75 | user <- fmap userLogin `fmap` with auth currentUser | |
| 76 | case user of | |
| 77 | Just u -> return (PasswdForm u) | |
| 78 | Nothing -> redirect "/" | |
| 79 | ||
| 80 | doPasswdChange :: AppHandler () | |
| 81 | doPasswdChange = do | |
| 82 | Just op <- getParam "oldpasswd" | |
| 83 | Just p1 <- getParam "p1" | |
| 84 | Just p2 <- getParam "p2" | |
| 85 | user <- with auth currentUser | |
| 86 | case user of | |
| 87 | Nothing -> finishWith basicError | |
| 88 | Just u -> | |
| 89 | case authenticatePassword u (ClearText op) of | |
| 90 | Just _ -> finishWith basicError | |
| 91 | Nothing -> | |
| 92 | if p1 /= p2 | |
| 93 | then finishWith basicError | |
| 94 | else do a <- liftIO (setPassword u p1) | |
| 95 | with auth $ saveUser a | |
| 96 | redirect "/" | |
| 97 | ||
| 98 | ||
| 99 | doLoginForm :: AppHandler Page | |
| 100 | doLoginForm = do | |
| 101 | user <- fmap userLogin `fmap` with auth currentUser | |
| 102 | return (LoginForm user) | |
| 103 | ||
| 104 | doNewUser :: AppHandler () | |
| 105 | doNewUser = do | |
| 106 | Just user <- getParam "user" | |
| 107 | Just passwd <- getParam "pass" | |
| 108 | with auth $ createUser (T.pack (unpack user)) passwd | |
| 109 | redirect "/" | |
| 110 | ||
| 111 | doCreate :: AppHandler Page | |
| 112 | doCreate = do | |
| 113 | user <- fmap userLogin `fmap` with auth currentUser | |
| 114 | case user of | |
| 115 | Just u -> return (Edit u def) | |
| 116 | _ -> redirect "/" | |
| 117 | ||
| 118 | doIndex :: AppHandler Page | |
| 119 | doIndex = do | |
| 120 | newest <- withSqlite newestPost | |
| 121 | user <- fmap userLogin `fmap` with auth currentUser | |
| 122 | case newest of | |
| 123 | Just pg -> return (Index user pg) | |
| 124 | Nothing -> finishWith basicError | |
| 125 | ||
| 126 | doAddPost :: AppHandler () | |
| 127 | doAddPost = do | |
| 128 | allowed <- with auth isLoggedIn | |
| 129 | if allowed then do | |
| 130 | rp <- getRawPost | |
| 131 | Just uname <- fmap userLogin `fmap` with auth currentUser | |
| 132 | success <- withSqlite (submitPost uname rp) | |
| 133 | if success | |
| 134 | then redirect "/" | |
| 135 | else finishWith basicError | |
| 136 | else | |
| 137 | redirect "/" | |
| 138 | ||
| 139 | doArchive :: AppHandler Page | |
| 140 | doArchive = do | |
| 141 | user <- fmap userLogin `fmap` with auth currentUser | |
| 142 | posts <- withSqlite listPosts | |
| 143 | return (List user posts) | |
| 144 | ||
| 145 | doOldestRedirect :: AppHandler () | |
| 146 | doOldestRedirect = do | |
| 147 | oldest <- withSqlite oldestPostRef | |
| 148 | case oldest of | |
| 149 | Nothing -> finishWith basicError | |
| 150 | Just pr -> do | |
| 151 | let url = urlFor pr | |
| 152 | redirect (encodeUtf8 url) | |
| 153 | ||
| 154 | doNewestRedirect :: AppHandler () | |
| 155 | doNewestRedirect = do | |
| 156 | newest <- withSqlite newestPostRef | |
| 157 | case newest of | |
| 158 | Nothing -> finishWith basicError | |
| 159 | Just pr -> do | |
| 160 | let url = urlFor pr | |
| 161 | redirect (encodeUtf8 url) | |
| 162 | ||
| 163 | toInt :: ByteString -> AppHandler Int | |
| 164 | toInt bs = case readInt bs of | |
| 165 | Just (n, "") -> return n | |
| 166 | _ -> finishWith basicError | |
| 167 | ||
| 168 | getPost :: AppHandler (Maybe Post) | |
| 169 | getPost = do | |
| 170 | Just year <- getParam "year" | |
| 171 | Just month <- getParam "month" | |
| 172 | Just slug <- getParam "slug" | |
| 173 | year' <- toInt year | |
| 174 | month' <- toInt month | |
| 175 | let slug' = unpack slug | |
| 176 | withSqlite (postByDateAndSlug year' month' slug') | |
| 177 | ||
| 178 | getRawPost :: AppHandler RawPost | |
| 179 | getRawPost = do | |
| 180 | Just rpTitle <- fmap decodeUtf8 `fmap` getParam "title" | |
| 181 | Just rpAuthor <- fmap decodeUtf8 `fmap` getParam "author" | |
| 182 | Just rpContents <- fmap decodeUtf8 `fmap` getParam "contents" | |
| 183 | idNum <- getParam "id" | |
| 184 | let rpId = case idNum of | |
| 185 | Just "none" -> Nothing | |
| 186 | _ -> maybe Nothing (fmap fst . readInt) idNum | |
| 187 | return RawPost { .. } | |
| 188 | ||
| 189 | doPost :: AppHandler Page | |
| 190 | doPost = do | |
| 191 | post <- getPost | |
| 192 | user <- fmap userLogin `fmap` with auth currentUser | |
| 193 | case post of | |
| 194 | Just pg -> return (Entry user pg) | |
| 195 | Nothing -> finishWith basicError | |
| 196 | ||
| 197 | editPost :: AppHandler Page | |
| 198 | editPost = do | |
| 199 | post <- getPost | |
| 200 | user <- fmap userLogin `fmap` with auth currentUser | |
| 201 | case (post, user) of | |
| 202 | (Just pg, Just u) | |
| 203 | | u == postAuthor pg -> return (Edit u (toRaw pg)) | |
| 204 | _ -> finishWith basicError | |
| 205 | ||
| 206 | doPage :: Page -> AppHandler () | |
| 207 | doPage = writeBuilder . renderHtmlBuilder . toMarkup |
| 1 | {-# LANGUAGE OverloadedStrings #-} | |
| 2 | ||
| 3 | module Site where | |
| 4 | ||
| 5 | import Application | |
| 6 | import Templates (errorPage) | |
| 7 | ||
| 8 | type Routes = [(B.ByteString, Handler App App ())] |
| 1 | {-# LANGUAGE OverloadedStrings, RecordWildCards, BangPatterns #-} | |
| 2 | ||
| 3 | module Templates(Page(..), errorPage) where | |
| 4 | ||
| 5 | import Types | |
| 6 | ||
| 7 | import Data.Maybe (isJust) | |
| 8 | import Data.Monoid ((<>)) | |
| 9 | import Data.Text (Text) | |
| 10 | import qualified Data.Text as T | |
| 11 | import Data.Time (UTCTime) | |
| 12 | import Data.Time.Format (formatTime) | |
| 13 | import Prelude (String, ($), (++), (==), return, Bool(..), Maybe(..), show) | |
| 14 | import qualified Prelude as P | |
| 15 | import System.Locale (defaultTimeLocale) | |
| 16 | import Text.Blaze.Html5 | |
| 17 | import Text.Blaze.Html5.Attributes hiding (title, form, span) | |
| 18 | import Text.Pandoc (writeHtml, readMarkdown, def) | |
| 19 | import Text.Pandoc.Options (WriterOptions(..)) | |
| 20 | ||
| 21 | -- A data representation of a page to be rendered | |
| 22 | data Page | |
| 23 | = Index (Maybe Text) Post | |
| 24 | | List (Maybe Text) [PostRef] | |
| 25 | | Entry (Maybe Text) Post | |
| 26 | | Edit Text RawPost | |
| 27 | | LoginForm (Maybe Text) | |
| 28 | | PasswdForm Text | |
| 29 | ||
| 30 | instance ToMarkup Page where | |
| 31 | toMarkup (Index lg post@(Post { .. })) = | |
| 32 | page lg postTitle (postBody lg post) | |
| 33 | toMarkup (List lg ps) = | |
| 34 | page lg "Past Entries" (listBody ps) | |
| 35 | toMarkup (Entry lg post@(Post { .. })) = | |
| 36 | page lg postTitle (postBody lg post) | |
| 37 | toMarkup (Edit uname rawPost) = | |
| 38 | page (Just uname) "Create Post" (editForm uname rawPost) | |
| 39 | toMarkup (PasswdForm uname) = | |
| 40 | page (Just uname) "Change Password" (passwdForm uname) | |
| 41 | toMarkup (LoginForm lg) = | |
| 42 | page lg "Log In" loginForm | |
| 43 | ||
| 44 | page :: Maybe Text -> Text -> Html -> Html | |
| 45 | page isLoggedIn pgName pgContents = docTypeHtml $ do | |
| 46 | head $ do | |
| 47 | meta ! charset "utf-8" | |
| 48 | link ! rel "stylesheet" ! type_ "text/css" ! href "/static/main.css" | |
| 49 | script ! src "/static/main.js" $ return () | |
| 50 | title (toHtml ("Baruk Khazâd: " `T.append` pgName)) | |
| 51 | body ! id "bg" $ do | |
| 52 | userText | |
| 53 | div ! class_ "title" $ h1 ("Baruk Khazâd! Khazâd ai-Mênu!") | |
| 54 | div ! class_ "nav" $ titlebar isLoggedIn | |
| 55 | div ! class_ "main" $ pgContents | |
| 56 | where userText = case isLoggedIn of | |
| 57 | Just user -> div ! class_ "username" $ do | |
| 58 | span ! class_ "msg" $ toMarkup ("Logged in as " <> user) | |
| 59 | " — " | |
| 60 | span ! class_ "lnk" $ do | |
| 61 | a ! href "/change" $ "Change My Password" | |
| 62 | " — " | |
| 63 | span ! class_ "lnk" $ do | |
| 64 | a ! href "/logout" $ "Log Out" | |
| 65 | Nothing -> return () | |
| 66 | ||
| 67 | errorPage :: Text -> Text -> Html | |
| 68 | errorPage err desc = page Nothing ("Error: " <> err ) $ do | |
| 69 | div ! class_ "errmsg" $ toMarkup desc | |
| 70 | ||
| 71 | passwdForm :: Text -> Html | |
| 72 | passwdForm uname = do | |
| 73 | form ! name "passwd" | |
| 74 | ! action "/change" | |
| 75 | ! method "POST" | |
| 76 | ! enctype "application/x-www-form-urlencoded;charset=UTF-8" $ do | |
| 77 | toMarkup ("Old password for " <> uname <> ": ") | |
| 78 | br | |
| 79 | input ! type_ "password" ! name "oldpasswd" | |
| 80 | br | |
| 81 | "New password: " | |
| 82 | br | |
| 83 | input ! type_ "password" ! name "p1" | |
| 84 | br | |
| 85 | input ! type_ "password" ! name "p2" | |
| 86 | br | |
| 87 | input ! type_ "submit" | |
| 88 | ||
| 89 | loginForm :: Html | |
| 90 | loginForm = div ! class_ "login" $ do | |
| 91 | form ! name "login" | |
| 92 | ! action "/login" | |
| 93 | ! method "POST" | |
| 94 | ! enctype "application/x-www-form-urlencoded;charset=UTF-8" $ do | |
| 95 | input ! type_ "text" ! name "user" | |
| 96 | br | |
| 97 | input ! type_ "password" ! name "passwd" | |
| 98 | br | |
| 99 | input ! type_ "submit" | |
| 100 | ||
| 101 | editForm :: Text -> RawPost -> Html | |
| 102 | editForm uname (RawPost { .. }) = div ! class_ "edit" $ do | |
| 103 | form ! name "newpost" | |
| 104 | ! action "/" | |
| 105 | ! method "POST" | |
| 106 | ! enctype "application/x-www-form-urlencoded;charset=UTF-8" $ do | |
| 107 | let idVal = case rpId of | |
| 108 | Nothing -> toValue ("none" :: String) | |
| 109 | Just n -> toValue n | |
| 110 | input ! type_ "hidden" ! name "id" ! value idVal | |
| 111 | "Title: " | |
| 112 | input ! type_ "text" ! name "title" ! value (toValue rpTitle) | |
| 113 | input ! type_ "hidden" ! name "author" ! value (toValue uname) | |
| 114 | br | |
| 115 | textarea ! cols "80" ! rows "40" ! name "contents" $ toHtml rpContents | |
| 116 | br | |
| 117 | input ! type_ "submit" | |
| 118 | ||
| 119 | titlebar :: Maybe Text -> Html | |
| 120 | titlebar user = P.mapM_ go links | |
| 121 | where go (lname, url) = a ! class_ "navitem" ! href url $ lname | |
| 122 | links = if isJust user then | |
| 123 | [ ("Newest", "/newest") | |
| 124 | , ("Archive", "/archive") | |
| 125 | , ("Create", "/create") | |
| 126 | , ("Oldest", "/oldest") | |
| 127 | ] | |
| 128 | else | |
| 129 | [ ("Newest", "/newest") | |
| 130 | , ("Archive", "/archive") | |
| 131 | , ("Log In", "/auth") | |
| 132 | , ("Oldest", "/oldest") | |
| 133 | ] | |
| 134 | ||
| 135 | postLink :: PostRef -> Html | |
| 136 | postLink (post@PostRef { .. }) = | |
| 137 | a ! href (toValue (urlFor post)) $ toHtml prName | |
| 138 | ||
| 139 | postBody :: Maybe Text -> Post -> Html | |
| 140 | postBody user (post@Post { .. }) = div ! class_ "post" $ do | |
| 141 | h2 (toHtml postTitle) | |
| 142 | div ! class_ "author" $ toHtml postAuthor | |
| 143 | ||
| 144 | let htmlOpts = def { writerHtml5 = True } | |
| 145 | let convPost = T.replace "\r\n" "\n" postContents | |
| 146 | writeHtml htmlOpts (readMarkdown def (T.unpack convPost)) | |
| 147 | editLink user | |
| 148 | div ! class_ "new" $ maybeLink postNext "Newer" | |
| 149 | div ! class_ "old" $ maybeLink postPrev "Older" | |
| 150 | where maybeLink Nothing _ = return () | |
| 151 | maybeLink (Just pr) n = postLink (pr { prName = n }) | |
| 152 | editLink (Just uname) | |
| 153 | | uname == postAuthor = a ! href editURL $ "Edit this post" | |
| 154 | editLink _ = return () | |
| 155 | editURL = toValue (urlForPost post <> "/edit") | |
| 156 | ||
| 157 | formatDate :: UTCTime -> String | |
| 158 | formatDate = formatTime defaultTimeLocale "%e %B, %Y" | |
| 159 | ||
| 160 | listBody :: [PostRef] -> Html | |
| 161 | listBody ps = div ! class_ "list" $ P.mapM_ go ps | |
| 162 | where go ref = do | |
| 163 | p $ do | |
| 164 | postLink ref | |
| 165 | span ! class_ "date" $ do | |
| 166 | " on " | |
| 167 | toMarkup (formatDate (prDate ref)) |
| 1 | {-# LANGUAGE RecordWildCards, ScopedTypeVariables, OverloadedStrings #-} | |
| 2 | ||
| 3 | module Types | |
| 4 | ( submitPost | |
| 5 | , newestPost | |
| 6 | , newestPostRef | |
| 7 | , oldestPostRef | |
| 8 | , listPosts | |
| 9 | , postByDateAndSlug | |
| 10 | ||
| 11 | , PostRef(..) | |
| 12 | , Post(..) | |
| 13 | , RawPost(..) | |
| 14 | ||
| 15 | , urlFor | |
| 16 | , urlForPost | |
| 17 | , toRaw | |
| 18 | ) where | |
| 19 | ||
| 20 | import Data.Char (isAlphaNum, toLower) | |
| 21 | import Data.Default (Default(..)) | |
| 22 | import Data.Maybe (listToMaybe) | |
| 23 | import Data.Text (Text) | |
| 24 | import qualified Data.Text as T | |
| 25 | import Data.Time (UTCTime(utctDay), toGregorian, getCurrentTime) | |
| 26 | import Database.SQLite.Simple | |
| 27 | ||
| 28 | singleResult :: IO [Only a] -> IO (Maybe a) | |
| 29 | singleResult = fmap (fmap fromOnly . listToMaybe) | |
| 30 | ||
| 31 | slugify :: Text -> Text | |
| 32 | slugify = T.map conv | |
| 33 | where conv c | isAlphaNum c = toLower c | |
| 34 | | otherwise = '-' | |
| 35 | ||
| 36 | submitPost :: Text -> RawPost -> Connection -> IO Bool | |
| 37 | submitPost uname rp c = case rpId rp of | |
| 38 | Just _ -> updatePost uname rp c | |
| 39 | Nothing -> insertPost uname rp c | |
| 40 | ||
| 41 | updatePost :: Text -> RawPost -> Connection -> IO Bool | |
| 42 | updatePost uname (RawPost { .. }) c = do | |
| 43 | if uname /= rpAuthor then return False else do | |
| 44 | let Just n = rpId | |
| 45 | execute c "UPDATE posts SET title = ?, author = ?, contents = ? WHERE id = ?" | |
| 46 | (rpTitle, rpAuthor, rpContents, n) | |
| 47 | execute c "UPDATE lookup SET slug = ? WHERE post_id = ?" (slugify rpTitle, n) | |
| 48 | return True | |
| 49 | ||
| 50 | insertPost :: Text -> RawPost -> Connection -> IO Bool | |
| 51 | insertPost uname (RawPost { .. }) c = do | |
| 52 | time <- getCurrentTime | |
| 53 | prev <- singleResult $ query_ c "SELECT id FROM posts ORDER BY time DESC LIMIT 1" | |
| 54 | execute c "INSERT INTO posts (title, contents, author, time, next, prev) VALUES (?,?,?,?,?,?)" | |
| 55 | (rpTitle, rpContents, uname, time, Nothing :: Maybe Int, prev :: Maybe Int) | |
| 56 | Just new <- singleResult $ query c "SELECT id FROM posts WHERE time = ?" (Only time) | |
| 57 | case prev of | |
| 58 | Just p -> execute c "UPDATE posts SET next = ? WHERE id = ?" (new :: Int, p) | |
| 59 | _ -> return () | |
| 60 | let (year, month, _) = toGregorian (utctDay time) | |
| 61 | execute c "INSERT INTO lookup (year, month, time, slug, post_id) VALUES (?,?,?,?,?)" | |
| 62 | (year, month, time, slugify rpTitle, new) | |
| 63 | return True | |
| 64 | ||
| 65 | newestPost :: Connection -> IO (Maybe Post) | |
| 66 | newestPost c = do | |
| 67 | [Only n] <- query_ c "SELECT id FROM posts ORDER BY time DESC LIMIT 1" | |
| 68 | postById n c | |
| 69 | ||
| 70 | newestPostRef :: Connection -> IO (Maybe PostRef) | |
| 71 | newestPostRef c = do | |
| 72 | [Only n] <- query_ c "SELECT id FROM posts ORDER BY time DESC LIMIT 1" | |
| 73 | postRefById n c | |
| 74 | ||
| 75 | oldestPostRef :: Connection -> IO (Maybe PostRef) | |
| 76 | oldestPostRef c = do | |
| 77 | [Only n] <- query_ c "SELECT id FROM posts ORDER BY time ASC LIMIT 1" | |
| 78 | postRefById n c | |
| 79 | ||
| 80 | listPosts :: Connection -> IO [PostRef] | |
| 81 | listPosts c = do | |
| 82 | posts <- query_ c "SELECT year, month, slug, post_id FROM lookup ORDER BY time DESC" | |
| 83 | mapM go posts | |
| 84 | where go (prYear, prMonth, prSlug, n :: Int) = | |
| 85 | do [(prName, prDate)] <- query c "SELECT title, time FROM posts WHERE id = ?" (Only n) | |
| 86 | return (PostRef { .. }) | |
| 87 | ||
| 88 | postById :: Int -> Connection -> IO (Maybe Post) | |
| 89 | postById n c = do | |
| 90 | vals <- query c "SELECT id, title, contents, author, time, next, prev FROM posts WHERE id = ?" (Only n) | |
| 91 | case vals of | |
| 92 | [] -> return Nothing | |
| 93 | (postId, postTitle, postContents, postAuthor, postDate, nextId, prevId):_ -> do | |
| 94 | postNext <- maybe (return Nothing) (flip postRefById c) nextId | |
| 95 | postPrev <- maybe (return Nothing) (flip postRefById c) prevId | |
| 96 | return (Just (Post { .. })) | |
| 97 | ||
| 98 | postByDateAndSlug :: Int -> Int -> String -> Connection -> IO (Maybe Post) | |
| 99 | postByDateAndSlug year month slug c = do | |
| 100 | vals <- query c "SELECT post_id FROM lookup WHERE year = ? AND month = ? AND slug = ?" | |
| 101 | (year, month, slug) | |
| 102 | case vals of | |
| 103 | [] -> return Nothing | |
| 104 | (Only n:_) -> postById n c | |
| 105 | ||
| 106 | postRefById :: Int -> Connection -> IO (Maybe PostRef) | |
| 107 | postRefById n c = do | |
| 108 | vals <- query c "SELECT year, month, slug, post_id FROM lookup WHERE id = ?" (Only n) | |
| 109 | case vals of | |
| 110 | [] -> return Nothing | |
| 111 | (prYear, prMonth, prSlug, postId :: Int):_ -> do | |
| 112 | (prName, prDate):_ <- query c "SELECT title, time FROM posts WHERE id = ?" (Only postId) | |
| 113 | return (Just (PostRef { .. })) | |
| 114 | ||
| 115 | -- Every post is referred to by a year, a month, a slug, and a name | |
| 116 | data PostRef = PostRef | |
| 117 | { prYear :: Int | |
| 118 | , prMonth :: Int | |
| 119 | , prSlug :: Text | |
| 120 | , prName :: Text | |
| 121 | , prDate :: UTCTime | |
| 122 | } deriving Show | |
| 123 | ||
| 124 | -- All the data for a particular post | |
| 125 | data Post = Post | |
| 126 | { postId :: Int | |
| 127 | , postDate :: UTCTime | |
| 128 | , postTitle :: Text | |
| 129 | , postContents :: Text | |
| 130 | , postAuthor :: Text | |
| 131 | , postNext :: Maybe PostRef | |
| 132 | , postPrev :: Maybe PostRef | |
| 133 | } deriving Show | |
| 134 | ||
| 135 | -- And all the data necessary to create a new post | |
| 136 | data RawPost = RawPost | |
| 137 | { rpId :: Maybe Int | |
| 138 | , rpTitle :: Text | |
| 139 | , rpAuthor :: Text | |
| 140 | , rpContents :: Text | |
| 141 | } deriving Show | |
| 142 | ||
| 143 | instance Default RawPost where | |
| 144 | def = RawPost Nothing "" "" "" | |
| 145 | ||
| 146 | urlForPost :: Post -> Text | |
| 147 | urlForPost (Post { .. }) = | |
| 148 | let (year, month, _) = toGregorian (utctDay postDate) in | |
| 149 | T.concat [ "/", T.pack (show year) | |
| 150 | , "/", T.pack (show month) | |
| 151 | , "/", slugify postTitle | |
| 152 | ] | |
| 153 | ||
| 154 | urlFor :: PostRef -> Text | |
| 155 | urlFor (PostRef { .. }) = | |
| 156 | T.concat [ "/", T.pack (show prYear) | |
| 157 | , "/", T.pack (show prMonth) | |
| 158 | , "/", prSlug | |
| 159 | ] | |
| 160 | ||
| 161 | toRaw :: Post -> RawPost | |
| 162 | toRaw (Post { .. }) = RawPost | |
| 163 | { rpId = Just postId | |
| 164 | , rpTitle = postTitle | |
| 165 | , rpContents = postContents | |
| 166 | , rpAuthor = postAuthor | |
| 167 | } |
Binary diff not shown
| 1 | body { | |
| 2 | font-family: "Arial", "Helvetica", sans-serif; | |
| 3 | background-color: #140d07; | |
| 4 | background-image: url('/static/back.gif'); | |
| 5 | background-attachment: fixed; | |
| 6 | color: #6b7355; | |
| 7 | } | |
| 8 | ||
| 9 | .username { | |
| 10 | width: 100%; | |
| 11 | background-color: rgba(176,189,140,0.8); | |
| 12 | color: #1d1309; | |
| 13 | text-align: center; | |
| 14 | margin-bottom: 10px; | |
| 15 | } | |
| 16 | ||
| 17 | .title { | |
| 18 | width:60%; | |
| 19 | margin-left: auto; | |
| 20 | margin-right: auto; | |
| 21 | text-align: center; | |
| 22 | padding: 1px; | |
| 23 | background-color: rgba(176,189,140,0.8); | |
| 24 | color: #1d1309; | |
| 25 | margin-bottom: 20px; | |
| 26 | -moz-border-radius: 15px; | |
| 27 | border-radius: 15px; | |
| 28 | } | |
| 29 | ||
| 30 | .nav { | |
| 31 | width: 60%; | |
| 32 | margin-left: auto; | |
| 33 | margin-right: auto; | |
| 34 | background-color: rgba(30,20,10,0.95); | |
| 35 | padding: 12px; | |
| 36 | margin-bottom: 10px; | |
| 37 | text-align: center; | |
| 38 | -moz-border-radius: 15px; | |
| 39 | border-radius: 15px; | |
| 40 | } | |
| 41 | ||
| 42 | .navitem { | |
| 43 | padding: 50px; | |
| 44 | } | |
| 45 | ||
| 46 | .main { | |
| 47 | width: 60%; | |
| 48 | margin-left: auto; | |
| 49 | margin-right: auto; | |
| 50 | background-color: rgba(30,20,10,0.95); | |
| 51 | padding-left: 20px; | |
| 52 | padding-right: 20px; | |
| 53 | padding-top: 10px; | |
| 54 | padding-bottom: 30px; | |
| 55 | -moz-border-radius: 15px; | |
| 56 | border-radius: 15px; | |
| 57 | } | |
| 58 | ||
| 59 | .main h2 { | |
| 60 | background-color: rgba(176,189,140,0.8); | |
| 61 | text-align: center; | |
| 62 | padding: 10px; | |
| 63 | -moz-border-radius: 15px; | |
| 64 | border-radius: 15px; | |
| 65 | color: #1d1309; | |
| 66 | } | |
| 67 | ||
| 68 | .author { | |
| 69 | font-style: italic; | |
| 70 | text-align: center; | |
| 71 | background-color: rgba(176,189,140,0.2); | |
| 72 | padding: 10px; | |
| 73 | -moz-border-radius: 15px; | |
| 74 | border-radius: 15px; | |
| 75 | width: 60%; | |
| 76 | margin-left: auto; | |
| 77 | margin-right: auto; | |
| 78 | } | |
| 79 | ||
| 80 | a { | |
| 81 | color: #fab40a; | |
| 82 | } | |
| 83 | ||
| 84 | a visited { | |
| 85 | color: #896305; | |
| 86 | } | |
| 87 | ||
| 88 | .new { | |
| 89 | display: inline-block; | |
| 90 | width: 50%; | |
| 91 | text-align: left; | |
| 92 | } | |
| 93 | ||
| 94 | .old { | |
| 95 | display: inline-block; | |
| 96 | width: 50%; | |
| 97 | text-align: right; | |
| 98 | }⏎ |
| 1 | function toDwarfMonth(t){ | |
| 2 | return(t.replace('January','Granite') | |
| 3 | .replace('February','Slate') | |
| 4 | .replace('March','Felsite') | |
| 5 | .replace('April','Hematite') | |
| 6 | .replace('May','Malachite') | |
| 7 | .replace('June','Galena') | |
| 8 | .replace('July','Limestone') | |
| 9 | .replace('August','Sandstone') | |
| 10 | .replace('September','Timber') | |
| 11 | .replace('October','Moonstone') | |
| 12 | .replace('November','Opal') | |
| 13 | .replace('December','Obsidian')); | |
| 14 | }; | |
| 15 | ||
| 16 | function fromDwarfMonth(t) { | |
| 17 | return(t.replace('Granite','January') | |
| 18 | .replace('Slate','February') | |
| 19 | .replace('Felsite','March') | |
| 20 | .replace('Hematite','April') | |
| 21 | .replace('Malachite','May') | |
| 22 | .replace('Galena','June') | |
| 23 | .replace('Limestone','July') | |
| 24 | .replace('Sandstone','August') | |
| 25 | .replace('Timber','September') | |
| 26 | .replace('Moonstone','October') | |
| 27 | .replace('Opal','November') | |
| 28 | .replace('Obsidian','December')); | |
| 29 | }; | |
| 30 | ||
| 31 | function dateReplace(f){ | |
| 32 | var spans=document.getElementsByTagName('span'); | |
| 33 | for(var i in spans) { | |
| 34 | if(spans[i].className&&spans[i].className.search('date')!== -1) | |
| 35 | spans[i].innerHTML=f(spans[i].innerHTML); | |
| 36 | } | |
| 37 | }; | |
| 38 | ||
| 39 | window.onload = function() { | |
| 40 | function toggle(){ | |
| 41 | is_dwarf=!is_dwarf; | |
| 42 | if (window.localStorage) | |
| 43 | window.localStorage.setItem('dwarvish',is_dwarf); | |
| 44 | dateReplace(is_dwarf?toDwarfMonth:fromDwarfMonth); | |
| 45 | }; | |
| 46 | function scroll() { | |
| 47 | var posY=( document.documentElement.scrollTop | |
| 48 | ? document.documentElement.scrollTop | |
| 49 | : window.pageYOffset); | |
| 50 | bg.style.backgroundPosition='' + (-posY*(0.01)) + 'px ' + (-posY*(0.1)) + 'px'; | |
| 51 | }; | |
| 52 | var bg=document.getElementById('bg'); | |
| 53 | var is_dwarf=(window.localStorage&&window.localStorage.getItem('dwarvish')=='true'); | |
| 54 | var spans=document.getElementsByTagName('span'); | |
| 55 | for (var i in spans) { | |
| 56 | if (spans[i].className&&spans[i].className.search('date')!==-1) { | |
| 57 | spans[i].onclick=toggle | |
| 58 | } | |
| 59 | } | |
| 60 | dateReplace(is_dwarf?toDwarfMonth:fromDwarfMonth); | |
| 61 | scroll(); | |
| 62 | ||
| 63 | window.onscroll=scroll; | |
| 64 | }; | |
| 65 |