{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE NoImplicitPrelude #-} {-# OPTIONS_GHC -fno-warn-missing-signatures #-} module Hero.Core where import Alpha import qualified Clay import Data.Aeson ( FromJSON (..), ToJSON (..), defaultOptions, genericParseJSON, genericToJSON, ) import Data.Data (Data, Typeable) import qualified Data.List as List import qualified Data.List.Split as List import Data.Proxy (Proxy (..)) import Data.String.Quote import Data.Text (Text) import GHC.Generics (Generic) import qualified GHC.Show as Legacy import Hero.Look as Look import Hero.Look.Typography import qualified Hero.Pack as Pack import Miso import qualified Miso (for_) import Miso.Extend import Miso.String import Network.RemoteData import Servant.API ( (:<|>) (..), (:>), ) import qualified Servant.API as Api import Servant.Links (linkURI) -- | The css id for controling music in the comic player. audioId :: MisoString audioId = "audioSource" -- TODO: make ComicId a hashid -- https://hackage.haskell.org/package/hashids-1.0.2.4/docs/Web-Hashids.html newtype ComicId = ComicId String deriving ( Show, Eq, Ord, Data, Typeable, Generic, ToMisoString, IsString, Api.ToHttpApiData, Api.FromHttpApiData ) instance ToJSON ComicId where toJSON = genericToJSON Data.Aeson.defaultOptions instance FromJSON ComicId where parseJSON = genericParseJSON Data.Aeson.defaultOptions -- | Used for looking up images on S3, mostly comicSlug :: Comic -> Text comicSlug Comic {..} = snake comicName <> "-" <> comicIssue -- * user data User = User { userEmail :: Text, userName :: Text, userLibrary :: [Comic] } deriving (Show, Eq, Generic, Data, Ord) instance Semigroup User where a <> b = User (userEmail a <> userEmail b) (userName a <> userName b) (userLibrary a <> userLibrary b) instance Monoid User where mempty = User mempty mempty mempty instance ToJSON User where toJSON = genericToJSON Data.Aeson.defaultOptions instance FromJSON User where parseJSON = genericParseJSON Data.Aeson.defaultOptions -- | Class for rendering media objects in different ways. class IsMediaObject o where -- | Render a thumbnail for use in a shelf, or otherwise. thumbnail :: o -> View Move -- | Render a featured banner. feature :: o -> User -> View Move -- | Media info view info :: o -> User -> View Move -- | How much to Zoom the comic image type Magnification = Int -- | All the buttons. data Button = Watch Comic | Read Comic | Save Comic User | SaveIcon Comic User | ZoomIcon Magnification Comic PageNumber | PlayPause MisoString AudioState | Arrow Move -- | Class for defining general, widely used elements in the heroverse. class Elemental v where el :: v -> View Move -- TODO: what if I just did this on all actions? -- then I could e.g. `el <| ToggleAudio audioId audioState` instance Elemental Button where el (PlayPause id form) = button_ [ class_ "button is-large icon", onClick <| ToggleAudio id ] [i_ [class_ <| "fa " <> icon] []] where icon = case form of Paused -> "fa-play-circle" Playing -> "fa-pause-circle" el (Arrow act) = button_ [class_ "button is-large turn-page", onClick act] [img_ [src_ <| ms <| Pack.demo <> image <> ".png"]] where image = case act of PrevPage -> "prev-page" NextPage -> "next-page" _ -> "prev-page" el (Save c u) = if c `elem` userLibrary u -- in library then a_ [class_ "wrs-button saved", onClick <| ToggleInLibrary c] [ img_ [src_ <| ms <| Pack.icon <> "save.svg"], span_ [] [text "saved"] ] else-- not in library a_ [class_ "wrs-button", onClick <| ToggleInLibrary c] [ img_ [src_ <| ms <| Pack.icon <> "save.svg"], span_ [] [text "save"] ] el (SaveIcon c u) = if c `elem` userLibrary u -- in library then button_ [ class_ "button is-large has-background-black", onClick <| ToggleInLibrary c ] [img_ [src_ <| ms <| Pack.demo <> "library-add.png"]] else-- not in library button_ [ class_ "button is-large has-background-black-bis", onClick <| ToggleInLibrary c ] [img_ [src_ <| ms <| Pack.demo <> "library-add.png"]] el (ZoomIcon zform comic page) = button_ [ id_ "zoom-button", class_ "button is-large", onClick <| ToggleZoom comic page ] [ img_ [src_ <| ms <| Pack.demo <> "zoom.png"], input_ [ type_ "range", min_ "0", max_ "100", disabled_ True, value_ <| ms (show zform :: String), class_ "ctrl", id_ "zoom" ], label_ [class_ "ctrl", Miso.for_ "zoom"] [text <| ms <| (show zform :: String) ++ "%"] ] el (Read c) = a_ [class_ "wrs-button", onClick <| SelectExperience c] [ img_ [src_ <| ms <| Pack.icon <> "read.svg"], span_ [] [text "read"] ] el (Watch c) = a_ [class_ "wrs-button", onClick <| StartWatching c] [ img_ [src_ <| ms <| Pack.icon <> "watch.svg"], span_ [] [text "watch"] ] data AudioState = Playing | Paused deriving (Show, Eq) data ComicReaderState = NotReading | Cover ComicId | ChooseExperience ComicId PageNumber | Reading ComicReaderView ComicId PageNumber | Watching ComicId deriving (Show, Eq) findComic :: ComicId -> [Comic] -> Maybe Comic findComic id = List.find (\c -> comicId c == id) -- | Main form for the app. -- -- Try to prefix component-specific state with the component initials: 'd' for -- discover, 'cp' for comic player. data Form = Form { uri :: Api.URI, appComics :: RemoteData MisoString [Comic], user :: User, dMediaInfo :: Maybe Comic, cpState :: ComicReaderState, cpAudioState :: AudioState, magnification :: Magnification } deriving (Show, Eq) initForm :: Api.URI -> Form initForm uri_ = Form { uri = uri_, appComics = NotAsked, dMediaInfo = Nothing, user = mempty, cpState = detectPlayerState uri_, cpAudioState = Paused, magnification = 100 } -- | Hacky way to initialize the 'ComicReaderState' from the Api.URI. detectPlayerState :: Api.URI -> ComicReaderState detectPlayerState u = case List.splitOn "/" <| Api.uriPath u of ["", "comic", id, pg, "experience"] -> ChooseExperience (ComicId id) (toPage pg) ["", "comic", id, _, "video"] -> Watching <| ComicId id ["", "comic", id, pg, "full"] -> Reading Full (ComicId id) (toPage pg) ["", "comic", id, pg] -> Reading Spread (ComicId id) (toPage pg) ["", "comic", id] -> Cover <| ComicId id _ -> NotReading where toPage pg = fromMaybe 1 (readMaybe pg :: Maybe PageNumber) type PageNumber = Int data Move = NoOp | -- comic player stuff SelectExperience Comic | StartReading Comic | StartWatching Comic | NextPage | PrevPage | ToggleZoom Comic PageNumber | ToggleAudio MisoString | FetchComics | SetComics (RemoteData MisoString [Comic]) | ToggleFullscreen | -- discover stuff SetMediaInfo (Maybe Comic) | ToggleInLibrary Comic | -- login ValidateUserPassword | -- app stuff ScrollIntoView MisoString | HandleURI Api.URI | ChangeURI Api.URI | Dumpform deriving (Show, Eq) type AppRoutes = Home :<|> ComicCover :<|> ComicReaderSpread :<|> ComicReaderFull :<|> ComicVideo :<|> Discover :<|> ChooseExperience handlers = home :<|> comicCover :<|> comicReader :<|> comicReader :<|> comicReader :<|> discover :<|> comicReader routes :: Proxy AppRoutes routes = Proxy type PubRoutes = Home :<|> Login pubRoutes :: Proxy PubRoutes pubRoutes = Proxy -- * pages -- -- TODO: consider making a typeclass, something like: -- -- class Page name where -- type Route name :: View Move -- proxy :: Proxy name -- proxy = Proxy name -- view :: form -> View Move -- link :: Api.URI -- ** home -- -- this is the unauthenticated page that you see when you first visit type Home = View Move homeProxy :: Proxy Home homeProxy = Proxy homeLink :: Api.URI homeLink = linkURI <| Api.safeLink front homeProxy where front = Proxy :: Proxy Home home :: form -> View Move home = login -- ** login data LoginForm = LoginForm {loginEmail :: String, loginPass :: String} deriving (Eq, Show, Read, Generic) instance ToJSON LoginForm instance FromJSON LoginForm type Login = "login" :> View Move loginProxy :: Proxy Login loginProxy = Proxy loginLink :: Api.URI loginLink = linkURI <| Api.safeLink pubRoutes loginProxy login :: form -> View Move login _ = template "login" [ div_ [id_ "login-inner"] [ img_ [ class_ fadeIn, src_ <| ms <| Pack.cdnEdge <> "/old-assets/images/icons/hero-large.png" ], hr_ [class_ fadeIn], form_ [class_ fadeIn] [ ctrl [id_ "user", class_ "input", type_ "email", placeholder_ "Email"], ctrl [id_ "pass", class_ "input", type_ "password", placeholder_ "Password"], div_ [class_ "action", css euro] [ div_ [class_ "checkbox remember-me"] [ input_ [type_ "checkbox"], label_ [Miso.for_ "checkbox"] [text "Remember Me"] ], div_ [class_ "button is-black", onClick ValidateUserPassword] [text "Login"] ] ], hr_ [class_ fadeIn], p_ [class_ <| "help " <> fadeIn] [ a_ [href_ "#"] [text "Forgot your username or password?"], a_ [href_ "#"] [text "Don't have an account? Sign Up"] ], img_ [ id_ "hero-logo", class_ "blur-out", src_ <| ms <| Pack.cdnEdge <> "/old-assets/images/icons/success-her-image.png" ] ] ] where fadeIn = "animated fadeIn delay-2s" ctrl x = div_ [class_ "control"] [input_ x] -- ** discover type Discover = "discover" :> View Move discoverLink :: Api.URI discoverLink = linkURI <| Api.safeLink routes discoverProxy discoverProxy :: Proxy Discover discoverProxy = Proxy discover :: Form -> View Move discover form@Form {user = u} = template "discover" [ topbar, main_ [id_ "app-body"] <| case appComics form of NotAsked -> [loading] Loading -> [loading] Failure _ -> [nocomics] Success [] -> [nocomics] Success (comic : rest) -> [ feature comic u, shelf "Recent Releases" (comic : rest), maybeView (`info` u) <| dMediaInfo form ], appmenu, discoverFooter ] discoverFooter :: View Move discoverFooter = footer_ [ id_ "app-foot", class_ "is-black" ] [ div_ [id_ "app-foot-social", css euro] [ div_ [class_ "row is-marginless"] [ smallImg "facebook.png" <| Just "https://www.facebook.com/musicmeetscomics", smallImg "twitter.png" <| Just "https://twitter.com/musicmeetscomic", smallImg "instagram.png" <| Just "https://www.instagram.com/musicmeetscomics/", smallImg "spotify.png" <| Just "https://open.spotify.com/user/i4ntfg6ganjgxdsylinigcjlq?si=ymWsSkwsT9iaLw2LeAJNNg", smallImg "youtube.png" <| Just "https://www.youtube.com/channel/UCnNPLiuJ1ueo1KTPgHDE7lA/" ], div_ [class_ "row"] [text "Team | Contact Us | Privacy Policy"] ], div_ [id_ "app-foot-quote", css euro] [ p_ [] [text "With great power comes great responsiblity."], p_ [] [text "-Stan Lee"] ], div_ [css euro, id_ "app-foot-logo", onClick Dumpform] [ a_ [class_ "social-icon", href_ "#"] [img_ [src_ <| ms <| Pack.icon <> "hero-logo.svg"]], span_ [] [text "© Hero Records, Inc. All Rights Reserved"] ] ] where attrs Nothing = [class_ "social-icon"] attrs (Just lnk) = [class_ "social-icon", href_ lnk, target_ "_blank"] smallImg x lnk = a_ (attrs lnk) [img_ [src_ <| ms <| Pack.cdnEdge <> "/old-assets/images/icons/" <> x]] -- ** comic data Comic = Comic { comicId :: ComicId, comicPages :: Integer, comicName :: Text, -- | Ideally this would be a dynamic number-like type comicIssue :: Text, comicDescription :: Text } deriving (Show, Eq, Generic, Data, Ord) instance ToJSON Comic where toJSON = genericToJSON Data.Aeson.defaultOptions instance FromJSON Comic where parseJSON = genericParseJSON Data.Aeson.defaultOptions instance IsMediaObject Comic where thumbnail c@Comic {..} = li_ [] [ a_ [ class_ "comic grow clickable", id_ <| "comic-" <> ms comicId, onClick <| SetMediaInfo <| Just c ] [ img_ [src_ <| ms <| Pack.demo <> comicSlug c <> ".png"], span_ [] [text <| "Issue #" <> ms comicIssue], span_ [] [text <| ms comicName] ] ] feature comic lib = div_ [id_ "featured-comic"] [ img_ [ id_ "featured-banner", src_ <| ms <| Pack.demo <> "feature-banner.png" ], div_ [id_ "featured-content"] [ div_ [class_ "hero-original", css wide] [ span_ [css thicc] [text "Herø"], span_ [css euro] [text " Original"] ], div_ [class_ "comic-logo"] [ img_ [ src_ <| ms <| Pack.demo <> comicSlug comic <> "-logo.png" ] ], div_ [class_ "comic-action-menu"] <| el "dmc-widethumb.png"]], column [ span_ [style_ title] [text <| ms comicName], span_ [style_ subtitle] [text <| "Issue #" <> ms comicIssue], span_ [] [text "Released: "], span_ [] [text <| "Pages: " <> ms (show comicPages :: String)] ] ], div_ [class_ "media-info-summary"] [ p_ [style_ <| uppercase <> bold <> Look.expanded <> "font-size" =: ".8rem"] [text "Summary"], p_ [] [text <| ms comicDescription] ], div_ [class_ "media-info-actions"] <| el "font-size" =: "1.6rem" <> uppercase <> "line-height" =: "100%" <> Look.condensed <> bold subtitle = "color" =: "#fff" <> "font-size" =: "1.2rem" <> bold <> Look.condensed type ComicCover = "comic" :> Api.Capture "comicId" ComicId :> View Move comicProxy :: Proxy ComicCover comicProxy = Proxy comicCover :: ComicId -> Form -> View Move comicCover comicId_ = comicReader comicId_ 1 comicLink :: ComicId -> Api.URI comicLink comicId_ = linkURI <| Api.safeLink routes comicProxy comicId_ -- ** chooseExperience type ChooseExperience = "comic" :> Api.Capture "id" ComicId :> Api.Capture "page" PageNumber :> "experience" :> View Move chooseExperienceProxy :: Proxy ChooseExperience chooseExperienceProxy = Proxy chooseExperienceLink :: ComicId -> PageNumber -> Api.URI chooseExperienceLink id page = linkURI <| Api.safeLink routes chooseExperienceProxy id page chooseExperiencePage :: Comic -> PageNumber -> Form -> View Move chooseExperiencePage comic page form = template "choose-experience" [ topbar, main_ [id_ "app-body"] [ h2_ [] [text "Choose Your Musical Experience"], p_ [] [text experienceBlurb], ul_ [] <| li comic name <> ".png"], span_ [] [text <| ms name] ], span_ [css thicc] [text <| ms artist], span_ [] [text <| ms track] ] experiences :: [(Text, Text, Text)] experiences = [ ("comedic", "RxGF", "Soft Reveal"), ("dark", "Logan Henderson", "Speak of the Devil"), ("original", "Mehcad Brooks", "Stars"), ("energetic", "Skela", "What's wrong with me"), ("dramatic", "Josh Jacobson", "Sideline") ] experienceBlurb :: MisoString experienceBlurb = [s| As you enter the world of Hero, you will find that music and visual art have a symbiotic relationship that can only be experienced, not described. Here, choose the tonality of the experience you wish to adventure on, whether it's a comedic, dark, energetic or dramatic. Feeling indecisive? Let us navigate your journey with the original curated music for this piece of visual art. |] -- ** comicReader data ComicReaderView = Spread | Full deriving (Show, Eq) comicReader :: ComicId -> PageNumber -> Form -> View Move comicReader _ _ form = case appComics form of NotAsked -> loading Loading -> loading Failure _ -> nocomics Success comics -> case cpState form of NotReading -> template "comic-player" [text "error: not reading"] Cover id -> viewOr404 comics comicSpread id 1 form ChooseExperience id pg -> viewOr404 comics chooseExperiencePage id pg form Reading Spread id pg -> viewOr404 comics comicSpread id pg form Reading Full id pg -> viewOr404 comics zoomScreen id pg form Watching id -> viewOr404 comics comicVideo id 0 form zoomScreen :: Comic -> PageNumber -> Form -> View Move zoomScreen comic page form = template "comic-player" [ topbar, main_ [id_ "app-body"] [ img_ [ src_ comicImg, class_ "comic-page-full" ] ], comicControls comic page form ] where comicImg = ms Pack.demo <> ms (comicSlug comic) <> "-" <> padLeft page <> ".png" -- ** comicReaderSpread type ComicReaderSpread = "comic" :> Api.Capture "id" ComicId :> Api.Capture "page" PageNumber :> View Move comicReaderSpreadProxy :: Proxy ComicReaderSpread comicReaderSpreadProxy = Proxy comicReaderSpreadLink :: ComicId -> PageNumber -> Api.URI comicReaderSpreadLink id page = linkURI <| Api.safeLink routes comicReaderSpreadProxy id page comicSpread :: Comic -> PageNumber -> Form -> View Move comicSpread comic page form = template "comic-player" [ topbar, main_ [id_ "app-body"] [ div_ [class_ "comic-player"] [ img_ [src_ comicImgLeft, class_ "comic-page"], img_ [src_ comicImgRight, class_ "comic-page"] ], closeButton ], appmenu, comicControls comic page form ] where comicImgLeft, comicImgRight :: MisoString comicImgLeft = ms Pack.demo <> ms (comicSlug comic) <> "-" <> padLeft page <> ".png" comicImgRight = ms Pack.demo <> ms (comicSlug comic) <> "-" <> padLeft (1 + page) <> ".png" closeButton :: View Move closeButton = a_ [id_ "close-button", onClick <| ChangeURI discoverLink] [text "x"] -- * comicReaderFull type ComicReaderFull = "comic" :> Api.Capture "id" ComicId :> Api.Capture "page" PageNumber :> "full" :> View Move comicReaderFullProxy :: Proxy ComicReaderFull comicReaderFullProxy = Proxy comicReaderFullLink :: ComicId -> PageNumber -> Api.URI comicReaderFullLink id page = linkURI <| Api.safeLink routes comicReaderFullProxy id page -- * comicVideo type ComicVideo = "comic" :> Api.Capture "id" ComicId :> Api.Capture "page" PageNumber :> "video" :> View Move comicVideoProxy :: Proxy ComicVideo comicVideoProxy = Proxy comicVideoLink :: ComicId -> PageNumber -> Api.URI comicVideoLink id page = linkURI <| Api.safeLink routes comicVideoProxy id page frameborder_ :: MisoString -> Attribute action frameborder_ = textProp "frameborder" allowfullscreen_ :: Bool -> Attribute action allowfullscreen_ = boolProp "allowfullscreen" comicVideo :: Comic -> PageNumber -> Form -> View Move comicVideo _ _ _ = template "comic-player" [ topbar, main_ [id_ "app-body"] [ div_ [class_ "comic-video"] [ iframe_ [ src_ "//player.vimeo.com/video/325757560", frameborder_ "0", allowfullscreen_ True ] [] ] ] ] -- * general page components & utils -- | If 'View' had a 'Monoid' instance, then '(text "")' could just be 'mempty' maybeView :: (a -> View action) -> Maybe a -> View action maybeView = maybe (text "") mediaInfo :: Maybe Comic -> User -> View Move mediaInfo Nothing _ = text "" mediaInfo (Just comic) user = div_ [class_ "media-info"] [info comic user] appmenu :: View Move appmenu = aside_ [id_ "appmenu"] <| btn img], span_ [] [text label] ] -- TODO: make this a loading gif of some sort... maybe the hero icon filling -- from white to red loading :: View Move loading = div_ [class_ "loading"] [text "Loading..."] nocomics :: View Move nocomics = div_ [class_ "loading"] [text "error: no comics found"] shelf :: IsMediaObject o => MisoString -> [o] -> View Move shelf title comics = div_ [class_ "shelf"] [ div_ [class_ "shelf-head"] [text title], ul_ [class_ "shelf-body"] <| thumbnail (Comic -> PageNumber -> form -> View Move) -> ComicId -> PageNumber -> form -> View Move viewOr404 comics f id pg form = case findComic id comics of Just c -> f c pg form Nothing -> the404 form template :: MisoString -> [View Move] -> View Move template id = div_ [id_ id, class_ "app is-black"] padLeft :: Int -> MisoString padLeft n | n < 10 = ms ("0" <> Legacy.show n) | otherwise = ms <| Legacy.show n comicControls :: Comic -> PageNumber -> Form -> View Move comicControls comic page form = footer_ [id_ "app-foot", class_ "comic-controls"] [ div_ [ class_ "comic-nav-audio", css flexCenter ] [ audio_ [id_ audioId, loop_ True, crossorigin_ "anonymous"] [source_ [src_ <| ms <| Pack.demo <> "stars-instrumental.mp3"]], el <| PlayPause audioId <| cpAudioState form, span_ [css <| euro <> thicc <> smol <> wide] [text "Experiencing: Original"] ], div_ [class_ "comic-controls-pages", css euro] [ el <| Arrow PrevPage, span_ [] [text <| leftPage <> "-" <> rightPage <> " of " <> totalpages], el <| Arrow NextPage ], div_ [class_ "comic-controls-share"] [ el <| SaveIcon comic <| user form, el <| ZoomIcon (magnification form) comic page, button_ [class_ "button icon is-large", onClick ToggleFullscreen] [i_ [class_ "fa fa-expand"] []] ] ] where leftPage = ms . Legacy.show <| page rightPage = ms . Legacy.show <| 1 + page totalpages = ms . Legacy.show <| comicPages comic topbar :: View Move topbar = header_ [id_ "app-head", class_ "is-black", css euro] [ a_ [ class_ "button is-medium is-black", onClick <| ChangeURI discoverLink ] [img_ [src_ <| ms <| Pack.icon <> "hero-logo.svg"]], div_ [id_ "app-head-right"] [ button_ [class_ "button icon is-medium is-black"] [i_ [class_ "fas fa-search"] []], button_ [ class_ "button is-medium is-black is-size-7", css <| euro <> wide <> thicc ] [text "News"], span_ [class_ "icon is-large"] [ i_ [class_ "fas fa-user"] [] ] ] ] row :: [View Move] -> View Move row = div_ [css <| Clay.display Clay.flex <> Clay.flexDirection Clay.row] column :: [View Move] -> View Move column = div_ [css <| Clay.display Clay.flex <> Clay.flexDirection Clay.column] -- | Links the404 :: form -> View Move the404 _ = template "404" [p_ [] [text "Not found"]]