├── .gitignore ├── README.md ├── elm-package.json ├── public ├── favicon.ico └── index.html ├── screenshot.png └── src ├── Api.elm ├── App.elm ├── Main.elm ├── SingleStory.elm ├── Stories.elm ├── Types.elm ├── Update.elm ├── UserProfile.elm ├── Utils.elm ├── index.js └── main.css /.gitignore: -------------------------------------------------------------------------------- 1 | # Distribution 2 | dist/ 3 | 4 | # elm-package generated files 5 | elm-stuff 6 | 7 | # elm-repl generated files 8 | repl-temp-* 9 | 10 | # Dependency directories 11 | node_modules 12 | 13 | # Desktop Services Store on macOS 14 | .DS_Store 15 | 16 | Makefile 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [elm-hn](https://elm-hn.surge.sh) 2 | 3 | Hacker News clone powered by [Elm](http://elm-lang.org/) + [elm-navigation](https://github.com/elm-lang/navigation) + [GraphQL](https://www.graphqlhub.com/). 4 | 5 | [![elm-hn screenshot](https://github.com/alpacaaa/elm-hn/raw/master/screenshot.png "Homepage")](https://elm-hn.surge.sh) 6 | 7 | Live version: https://elm-hn.surge.sh 8 | 9 | ## Features 10 | 11 | * Supports display of all item types: 12 | [stories](https://elm-hn.surge.sh/story/8863), 13 | [jobs](https://elm-hn.surge.sh/story/8426937) and 14 | [polls](https://elm-hn.surge.sh/story/126809) 15 | * Basic [user profiles](https://elm-hn.surge.sh/user/patio11) 16 | * Collapsible comment threads, with child counts 17 | 18 | It might take a few seconds to get a response from the server, so old tight! All errors are displayed on screen. 19 | 20 | The backend api is a bit quirky in that it doesn't return all the results and has some inconsistencies in the data shown. Because of this, some of the JSON decoders are quite tricky and studying them can be a good exercise for new comers. The point of this projects is to work on a real Elm app so I'm not too worried about what gets returned by the api. 21 | 22 | ## Running 23 | 24 | Make sure you have `create-elm-app` installed: 25 | 26 | ``` 27 | npm install -g create-elm-app 28 | ``` 29 | 30 | Run dev server with: 31 | ``` 32 | elm-app start 33 | ``` 34 | 35 | Build in `dist` folder: 36 | ``` 37 | elm-app build 38 | ``` 39 | ---- 40 | Inspired by [react-hn](https://react-hn.appspot.com) -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "Hacker News clone", 4 | "repository": "https://github.com/alpacaaa/elm-hn.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | ".", 8 | "src/" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", 13 | "alpacaaa/elm-date-distance": "1.0.3 <= v < 2.0.0", 14 | "elm-community/maybe-extra": "4.0.0 <= v < 5.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 17 | "elm-lang/http": "1.0.0 <= v < 2.0.0", 18 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 19 | "krisajenkins/remotedata": "4.3.0 <= v < 5.0.0", 20 | "sporto/erl": "11.1.1 <= v < 12.0.0" 21 | }, 22 | "elm-version": "0.18.0 <= v < 0.19.0" 23 | } 24 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacaaa/elm-hn/f088967d26e5a6ebced92da737a6ff0aef71a3d1/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Elm HN 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacaaa/elm-hn/f088967d26e5a6ebced92da737a6ff0aef71a3d1/screenshot.png -------------------------------------------------------------------------------- /src/Api.elm: -------------------------------------------------------------------------------- 1 | module Api 2 | exposing 3 | ( fetchStories 4 | , fetchStory 5 | , fetchUser 6 | ) 7 | 8 | import Http 9 | import Json.Decode as Decode 10 | import Json.Decode.Pipeline as Pipeline 11 | import Json.Encode as Encode 12 | import Maybe.Extra as Maybe 13 | import Types exposing (..) 14 | 15 | 16 | type alias Field = 17 | { name : String 18 | , args : Args 19 | , query : Query 20 | } 21 | 22 | 23 | type Query 24 | = Query (List Field) 25 | 26 | 27 | type alias Args = 28 | List ( String, String ) 29 | 30 | 31 | storiesQuery : StoryType -> Int -> Query 32 | storiesQuery storyType offset = 33 | Query 34 | [ field "hn" 35 | [ fieldWithArgs (storyTypeString storyType) 36 | [ ( "offset", toString offset ), ( "limit", "30" ) ] 37 | [ field "id" [] 38 | , field "url" [] 39 | , field "title" [] 40 | , field "score" [] 41 | , field "time" [] 42 | , field "by" 43 | [ field "id" [] 44 | ] 45 | , field "descendants" [] 46 | , field "deleted" [] 47 | , field "dead" [] 48 | ] 49 | ] 50 | ] 51 | 52 | 53 | storyQuery : String -> Query 54 | storyQuery id = 55 | Query 56 | [ field "hn" 57 | [ fieldWithArgs "item" 58 | [ ( "id", id ) ] 59 | [ field "id" [] 60 | , field "url" [] 61 | , field "title" [] 62 | , field "text" [] 63 | , field "score" [] 64 | , field "time" [] 65 | , field "by" 66 | [ field "id" [] 67 | ] 68 | , fetchKids 5 69 | , field "deleted" [] 70 | , field "dead" [] 71 | , field "parts" 72 | [ field "text" [] 73 | , field "score" [] 74 | ] 75 | ] 76 | ] 77 | ] 78 | 79 | 80 | userQuery : String -> Query 81 | userQuery id = 82 | Query 83 | [ field "hn" 84 | [ fieldWithArgs "user" 85 | [ ( "id", toString id ) ] 86 | [ field "id" [] 87 | , field "created" [] 88 | , field "about" [] 89 | ] 90 | ] 91 | ] 92 | 93 | 94 | commentFields : List Field 95 | commentFields = 96 | [ field "id" [] 97 | , field "text" [] 98 | , field "score" [] 99 | , field "time" [] 100 | , field "by" 101 | [ field "id" [] 102 | ] 103 | , field "deleted" [] 104 | , field "dead" [] 105 | ] 106 | 107 | 108 | runQuery : Query -> Decode.Decoder a -> String -> Http.Request a 109 | runQuery query decoder returnField = 110 | let 111 | queryStr = 112 | queryToString query 113 | 114 | jsonBody = 115 | Http.jsonBody <| 116 | Encode.object 117 | [ ( "query", Encode.string queryStr ) ] 118 | 119 | finalDecoder = 120 | Decode.at [ "data", "hn", returnField ] decoder 121 | in 122 | Http.post "https://www.graphqlhub.com/graphql" jsonBody finalDecoder 123 | 124 | 125 | fetchStories : StoryType -> Int -> Http.Request (List Story) 126 | fetchStories storyType offset = 127 | let 128 | query = 129 | storiesQuery storyType offset 130 | 131 | skipNull = 132 | Decode.map Maybe.values 133 | 134 | decoder = 135 | Decode.list (Decode.nullable storyDecoder) 136 | |> skipNull 137 | |> skipDeleted 138 | in 139 | runQuery query decoder (storyTypeString storyType) 140 | 141 | 142 | fetchStory : String -> Http.Request Story 143 | fetchStory id = 144 | runQuery (storyQuery id) storyDecoder "item" 145 | 146 | 147 | fetchUser : String -> Http.Request User 148 | fetchUser id = 149 | runQuery (userQuery id) userDecoder "user" 150 | 151 | 152 | fetchKids : Int -> Field 153 | fetchKids count = 154 | let 155 | fields = 156 | if count == 0 then 157 | commentFields 158 | else 159 | fetchKids (count - 1) :: commentFields 160 | in 161 | field "kids" fields 162 | 163 | 164 | skipDeleted : 165 | Decode.Decoder (List { a | deleted : Bool, dead : Bool }) 166 | -> Decode.Decoder (List { a | deleted : Bool, dead : Bool }) 167 | skipDeleted = 168 | let 169 | skip = 170 | List.filter (\{ deleted, dead } -> not <| deleted || dead) 171 | in 172 | Decode.map skip 173 | 174 | 175 | storyDecoder : Decode.Decoder Story 176 | storyDecoder = 177 | Pipeline.decode Story 178 | |> Pipeline.required "id" Decode.string 179 | |> Pipeline.required "title" Decode.string 180 | |> Pipeline.optional "text" (Decode.nullable Decode.string) Nothing 181 | |> Pipeline.optional "score" Decode.int 0 182 | |> Pipeline.optionalAt [ "by", "id" ] Decode.string "" 183 | |> Pipeline.optional "time" Decode.int 0 184 | |> Pipeline.optional "descendants" (Decode.nullable Decode.int) Nothing 185 | |> Pipeline.optional "kids" (Decode.list commentDecoder |> skipDeleted) [] 186 | |> Pipeline.optional "url" (Decode.nullable Decode.string) Nothing 187 | |> Pipeline.optional "deleted" Decode.bool False 188 | |> Pipeline.optional "dead" Decode.bool False 189 | |> Pipeline.optional "parts" (Decode.nullable <| Decode.list pollDecoder) Nothing 190 | 191 | 192 | kidsDecoder : Decode.Decoder Kids 193 | kidsDecoder = 194 | Decode.lazy (\_ -> Decode.list commentDecoder |> skipDeleted |> Decode.map Kids) 195 | 196 | 197 | commentDecoder : Decode.Decoder Comment 198 | commentDecoder = 199 | Pipeline.decode Comment 200 | |> Pipeline.required "id" Decode.string 201 | |> Pipeline.optional "text" Decode.string "" 202 | |> Pipeline.optional "score" Decode.int 0 203 | |> Pipeline.optionalAt [ "by", "id" ] Decode.string "" 204 | |> Pipeline.optional "time" Decode.int 0 205 | |> Pipeline.optional "kids" kidsDecoder (Kids []) 206 | |> Pipeline.optional "deleted" Decode.bool False 207 | |> Pipeline.optional "dead" Decode.bool False 208 | 209 | 210 | userDecoder : Decode.Decoder User 211 | userDecoder = 212 | Pipeline.decode User 213 | |> Pipeline.required "id" Decode.string 214 | |> Pipeline.optional "created" Decode.int 0 215 | |> Pipeline.optional "about" (Decode.nullable Decode.string) Nothing 216 | 217 | 218 | pollDecoder : Decode.Decoder PollOption 219 | pollDecoder = 220 | Pipeline.decode PollOption 221 | |> Pipeline.required "text" Decode.string 222 | |> Pipeline.required "score" Decode.int 223 | 224 | 225 | field : String -> List Field -> Field 226 | field name fields = 227 | Field name [] (Query fields) 228 | 229 | 230 | fieldWithArgs : String -> Args -> List Field -> Field 231 | fieldWithArgs name args fields = 232 | Field name args (Query fields) 233 | 234 | 235 | queryToString : Query -> String 236 | queryToString (Query query) = 237 | if List.isEmpty query then 238 | "" 239 | else 240 | let 241 | str = 242 | List.map fieldToString query 243 | |> List.foldr (++) "" 244 | in 245 | "{ " ++ str ++ " }" 246 | 247 | 248 | fieldToString : Field -> String 249 | fieldToString { name, args, query } = 250 | name ++ " " ++ argsToString args ++ " " ++ queryToString query 251 | 252 | 253 | argsToString : Args -> String 254 | argsToString args = 255 | if List.isEmpty args then 256 | "" 257 | else 258 | let 259 | transform = 260 | \( key, value ) -> key ++ ": " ++ value 261 | 262 | str = 263 | List.map transform args 264 | |> List.foldr (++) " " 265 | in 266 | "( " ++ str ++ " )" 267 | 268 | 269 | storyTypeString : StoryType -> String 270 | storyTypeString storyType = 271 | case storyType of 272 | Top -> 273 | "topStories" 274 | 275 | Newest -> 276 | "newStories" 277 | 278 | Show -> 279 | "showStories" 280 | 281 | Ask -> 282 | "askStories" 283 | 284 | Jobs -> 285 | "jobStories" 286 | -------------------------------------------------------------------------------- /src/App.elm: -------------------------------------------------------------------------------- 1 | module App exposing (view) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (alt, class, classList, height, href, src, style, width) 5 | import Maybe.Extra as Maybe 6 | import RemoteData exposing (RemoteData(..), WebData) 7 | import SingleStory 8 | import Stories 9 | import Time exposing (Time) 10 | import Types exposing (..) 11 | import UserProfile 12 | import Utils exposing (formatTime, innerHtml, maybeRender) 13 | 14 | 15 | type alias HeaderLinkConfig = 16 | { storyType : StoryType 17 | , path : String 18 | , text : String 19 | , class : String 20 | } 21 | 22 | 23 | view : Model -> Html Msg 24 | view model = 25 | div [ class "App" ] 26 | [ div [ class "App__wrap" ] 27 | [ header model.route 28 | , div [ class "App__content" ] 29 | [ mainContent model ] 30 | , div [ class "App__footer" ] 31 | [ a [ href "https://github.com/alpacaaa/elm-hn" ] [ text "elm-hn" ] ] 32 | ] 33 | ] 34 | 35 | 36 | header : Route -> Html Msg 37 | header route = 38 | let 39 | activeStoryType = 40 | routeToStoryType route 41 | 42 | elmHn = 43 | { storyType = Top 44 | , path = "/" 45 | , class = "App__homelink" 46 | , text = "Elm HN" 47 | } 48 | 49 | new = 50 | { storyType = Newest 51 | , path = "/newest" 52 | , class = "" 53 | , text = "new" 54 | } 55 | 56 | show = 57 | { storyType = Show 58 | , path = "/show" 59 | , class = "" 60 | , text = "show" 61 | } 62 | 63 | ask = 64 | { storyType = Ask 65 | , path = "/ask" 66 | , class = "" 67 | , text = "ask" 68 | } 69 | 70 | jobs = 71 | { storyType = Jobs 72 | , path = "/jobs" 73 | , class = "" 74 | , text = "jobs" 75 | } 76 | 77 | navLinks = 78 | [ new, show, ask, jobs ] 79 | |> List.map (headerLink activeStoryType) 80 | |> List.intersperse (text " | ") 81 | in 82 | div [ class "App__header" ] 83 | [ a [ class "App__homelinkicon" ] [] 84 | , headerLink activeStoryType elmHn 85 | , span [] navLinks 86 | ] 87 | 88 | 89 | routeToStoryType : Route -> Maybe StoryType 90 | routeToStoryType route = 91 | case route of 92 | StoriesPageRoute storyType _ -> 93 | Just storyType 94 | 95 | StoryRoute _ -> 96 | Nothing 97 | 98 | UserRoute _ -> 99 | Nothing 100 | 101 | NotFoundRoute -> 102 | Nothing 103 | 104 | 105 | headerLink : Maybe StoryType -> HeaderLinkConfig -> Html Msg 106 | headerLink activeStoryType config = 107 | let 108 | link = 109 | Utils.href config.path 110 | 111 | isActive = 112 | Maybe.unwrap False ((==) config.storyType) activeStoryType 113 | 114 | classes = 115 | classList 116 | [ ( config.class, True ) 117 | , ( "active", isActive ) 118 | ] 119 | in 120 | a (link ++ [ classes ]) [ text config.text ] 121 | 122 | 123 | mainContent : Model -> Html Msg 124 | mainContent model = 125 | case model.route of 126 | StoriesPageRoute storyType data -> 127 | remoteConentStories data model.now 128 | 129 | StoryRoute { story, collapsedComments } -> 130 | remoteContent story (SingleStory.page { now = model.now, collapsedComments = collapsedComments }) 131 | 132 | UserRoute { user } -> 133 | remoteContent user (UserProfile.page model.now) 134 | 135 | NotFoundRoute -> 136 | notFoundView 137 | 138 | 139 | remoteContent : WebData a -> (a -> Html Msg) -> Html Msg 140 | remoteContent data createHtml = 141 | case data of 142 | NotAsked -> 143 | text "" 144 | 145 | Loading -> 146 | loadingView 147 | 148 | Success a -> 149 | createHtml a 150 | 151 | Failure err -> 152 | errorView (toString err) 153 | 154 | 155 | remoteConentStories : StoryList -> Time -> Html Msg 156 | remoteConentStories { page, stories } now = 157 | remoteContent stories (Stories.page { now = now, page = page }) 158 | 159 | 160 | genericView : List (Html Msg) -> Html Msg 161 | genericView content = 162 | div [ class "Items" ] 163 | [ ol [ class "Items__list" ] 164 | [ div [ class "Item" ] content ] 165 | ] 166 | 167 | 168 | notFoundView : Html Msg 169 | notFoundView = 170 | genericView 171 | [ div [ class "Item__title" ] [ text "Not Found" ] 172 | ] 173 | 174 | 175 | loadingView : Html Msg 176 | loadingView = 177 | genericView 178 | [ div [ class "Item__title" ] [ text "Loading..." ] 179 | , p [] [ text "api is a bit slow, hold on." ] 180 | ] 181 | 182 | 183 | errorView : String -> Html Msg 184 | errorView err = 185 | genericView 186 | [ text "Good News! Something blew up ¯\\_(ツ)_/¯ Here's the error." 187 | , pre [] [ code [] [ text err ] ] 188 | ] 189 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import App 4 | import Navigation 5 | import Types 6 | import Update 7 | 8 | 9 | main : Program Never Types.Model Types.Msg 10 | main = 11 | Navigation.program Update.onLocationChange 12 | { view = App.view 13 | , init = Update.init 14 | , update = Update.update 15 | , subscriptions = Update.subscriptions 16 | } 17 | -------------------------------------------------------------------------------- /src/SingleStory.elm: -------------------------------------------------------------------------------- 1 | module SingleStory exposing (page, renderStory) 2 | 3 | import Erl as Url 4 | import Html exposing (..) 5 | import Html.Attributes exposing (class, href) 6 | import Html.Events exposing (onClick) 7 | import Maybe.Extra as Maybe 8 | import Set 9 | import Time exposing (Time) 10 | import Types exposing (..) 11 | import Utils exposing (formatTime, innerHtml, maybeRender) 12 | 13 | 14 | type alias SingleStoryContext = 15 | { now : Time 16 | , collapsedComments : Set.Set String 17 | } 18 | 19 | 20 | page : SingleStoryContext -> Story -> Html Msg 21 | page ctx story = 22 | div [ class "Items" ] 23 | [ ol [ class "Items__list" ] [ itemDetail ctx story ] 24 | ] 25 | 26 | 27 | renderStory : { a | now : Time } -> Story -> List (Html Msg) 28 | renderStory { now } story = 29 | [ div [ class "Item__title" ] 30 | [ storyTitle story 31 | , text " " 32 | , maybeRender renderHost story.url 33 | ] 34 | , div [ class "Item__meta" ] 35 | [ span [ class "Item__score" ] [ text <| toString story.score ++ " points" ] 36 | , text " " 37 | , span [ class "Item__by" ] 38 | [ a (Utils.href (linkToUser story.user)) [ text story.user ] 39 | ] 40 | , text " " 41 | , time [ class "Item__time" ] [ text <| formatTime now story.time ] 42 | , maybeRender (\_ -> text " | ") story.commentsCount 43 | , maybeRender (renderCommentsCount story.id) story.commentsCount 44 | ] 45 | , maybeRender (\text -> div [ class "Item__text" ] [ div [ innerHtml text ] [] ]) story.text 46 | , maybeRender renderPoll story.poll 47 | ] 48 | 49 | 50 | itemDetail : SingleStoryContext -> Story -> Html Msg 51 | itemDetail ctx story = 52 | div [ class "Item" ] 53 | [ div [ class "Item__content" ] <| renderStory ctx story 54 | , div [ class "Item__kids" ] <| commentsTree ctx story 55 | ] 56 | 57 | 58 | renderHost : String -> Html Msg 59 | renderHost url = 60 | let 61 | hostParts = 62 | Url.extractHost url 63 | |> String.split "." 64 | 65 | host = 66 | String.join "." <| List.drop (List.length hostParts - 2) hostParts 67 | in 68 | if String.length host > 0 then 69 | span [ class "Item__host" ] [ text <| "(" ++ host ++ ")" ] 70 | else 71 | text "" 72 | 73 | 74 | linkToStory : String -> String 75 | linkToStory id = 76 | "/story/" ++ id 77 | 78 | 79 | linkToUser : String -> String 80 | linkToUser id = 81 | "/user/" ++ id 82 | 83 | 84 | storyTitle : Story -> Html Msg 85 | storyTitle story = 86 | let 87 | url = 88 | Maybe.withDefault (linkToStory story.id) story.url 89 | 90 | link = 91 | Maybe.unwrap (Utils.href url) (\external -> [ href external ]) story.url 92 | in 93 | a link [ text story.title ] 94 | 95 | 96 | collapsible : String -> Collapsible -> Html Msg 97 | collapsible id collapsed = 98 | let 99 | symbol = 100 | case collapsed of 101 | Open -> 102 | "–" 103 | 104 | Closed -> 105 | "+" 106 | 107 | wrapped = 108 | "[" ++ symbol ++ "]" 109 | in 110 | span 111 | [ class "Comment__collapse", onClick (ToggleCollapse id collapsed) ] 112 | [ text wrapped ] 113 | 114 | 115 | renderCommentsCount : String -> Int -> Html Msg 116 | renderCommentsCount id comments = 117 | let 118 | str = 119 | if comments == 0 then 120 | "discuss" 121 | else 122 | toString comments ++ " comments" 123 | in 124 | a (Utils.href (linkToStory id)) [ text str ] 125 | 126 | 127 | commentsTree : SingleStoryContext -> Story -> List (Html Msg) 128 | commentsTree ctx story = 129 | List.map (singleComment ctx 0) story.comments 130 | 131 | 132 | kids : Kids -> List Comment 133 | kids (Kids comments) = 134 | comments 135 | 136 | 137 | singleComment : SingleStoryContext -> Int -> Comment -> Html Msg 138 | singleComment ctx level comment = 139 | let 140 | comments = 141 | kids comment.kids 142 | 143 | isCollapsed = 144 | Set.member comment.id ctx.collapsedComments 145 | 146 | collapsed = 147 | if isCollapsed then 148 | Closed 149 | else 150 | Open 151 | 152 | collapsedClass = 153 | case collapsed of 154 | Open -> 155 | "" 156 | 157 | Closed -> 158 | "Comment--collapsed" 159 | 160 | newLevel = 161 | singleComment ctx (level + 1) 162 | 163 | levelClass = 164 | "Comment--level" ++ toString level 165 | 166 | classes = 167 | [ "Comment", levelClass, collapsedClass ] 168 | |> String.join " " 169 | in 170 | div [ class classes ] 171 | [ div [ class "Comment__content" ] 172 | [ commentMeta ctx comment collapsed 173 | , commentText comment 174 | ] 175 | , div [ class "Comment__kids" ] <| List.map newLevel comments 176 | ] 177 | 178 | 179 | commentMeta : SingleStoryContext -> Comment -> Collapsible -> Html Msg 180 | commentMeta { now } comment collapsed = 181 | let 182 | link = 183 | "https://news.ycombinator.com/item?id=" ++ comment.id 184 | in 185 | div [ class "Comment__meta" ] 186 | [ collapsible comment.id collapsed 187 | , text " " 188 | , a (Utils.href (linkToUser comment.user) ++ [ class "Comment__user" ]) [ text comment.user ] 189 | , text " " 190 | , a [ href link ] 191 | [ time [] [ text <| formatTime now comment.time ] 192 | ] 193 | ] 194 | 195 | 196 | commentText : Comment -> Html Msg 197 | commentText comment = 198 | let 199 | link = 200 | "https://news.ycombinator.com/reply?id=" ++ comment.id 201 | in 202 | div [ class "Comment__text" ] 203 | [ div [ innerHtml comment.text ] [] 204 | , p [] 205 | [ a [ href link ] [ text "reply" ] 206 | ] 207 | ] 208 | 209 | 210 | renderPoll : List PollOption -> Html Msg 211 | renderPoll poll = 212 | div [ class "Item__poll" ] <| List.map renderPollOption poll 213 | 214 | 215 | renderPollOption : PollOption -> Html Msg 216 | renderPollOption option = 217 | div [ class "PollOption" ] 218 | [ div [ class "PollOption__text", innerHtml option.text ] [] 219 | , div [ class "PollOption__score" ] [ text <| toString option.score ++ " points" ] 220 | ] 221 | -------------------------------------------------------------------------------- /src/Stories.elm: -------------------------------------------------------------------------------- 1 | module Stories exposing (page) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (class, href) 5 | import SingleStory 6 | import Time exposing (Time) 7 | import Types exposing (..) 8 | 9 | 10 | type alias StoriesContext = 11 | { now : Time 12 | , page : Int 13 | } 14 | 15 | 16 | page : StoriesContext -> List Story -> Html Msg 17 | page ctx stories = 18 | div [ class "Items" ] 19 | [ ol 20 | [ class "Items__list", listStartAttribute ctx.page ] 21 | (List.map (listItemNews ctx) stories) 22 | , paginator ctx.page 23 | ] 24 | 25 | 26 | listStartAttribute : Int -> Html.Attribute a 27 | listStartAttribute page = 28 | let 29 | index = 30 | (page - 1) * 30 + 1 31 | in 32 | Html.Attributes.start index 33 | 34 | 35 | listItemNews : StoriesContext -> Story -> Html Msg 36 | listItemNews ctx story = 37 | li [ class "ListItem" ] (SingleStory.renderStory ctx story) 38 | 39 | 40 | paginator : Int -> Html Msg 41 | paginator page = 42 | let 43 | next = 44 | "?page=" ++ (toString <| page + 1) 45 | in 46 | div [ class "Paginator" ] 47 | [ span [ class "Paginator__next" ] 48 | [ a [ href next ] [ text "More" ] 49 | ] 50 | ] 51 | -------------------------------------------------------------------------------- /src/Types.elm: -------------------------------------------------------------------------------- 1 | module Types 2 | exposing 3 | ( Collapsible(..) 4 | , Comment 5 | , Kids(..) 6 | , Model 7 | , Msg(..) 8 | , PollOption 9 | , Route(..) 10 | , Story 11 | , StoryList 12 | , StoryType(..) 13 | , User 14 | ) 15 | 16 | import Http 17 | import RemoteData exposing (WebData) 18 | import Set 19 | import Time exposing (Time) 20 | 21 | 22 | type alias Model = 23 | { now : Time 24 | , route : Route 25 | } 26 | 27 | 28 | type alias StoryList = 29 | { page : Int, stories : WebData (List Story) } 30 | 31 | 32 | type Route 33 | = StoriesPageRoute StoryType StoryList 34 | | StoryRoute { id : String, story : WebData Story, collapsedComments : Set.Set String } 35 | | UserRoute { id : String, user : WebData User } 36 | | NotFoundRoute 37 | 38 | 39 | type Msg 40 | = NoOp 41 | | FetchHNStories StoryType (WebData (List Story)) 42 | | FetchHNStory (WebData Story) 43 | | FetchHNUser (WebData User) 44 | | CurrentTime Time 45 | | RouteUpdate Route 46 | | Go String 47 | | ToggleCollapse String Collapsible 48 | 49 | 50 | type alias Story = 51 | { id : String 52 | , title : String 53 | , text : Maybe String 54 | , score : Int 55 | , user : String 56 | , time : Int 57 | , commentsCount : Maybe Int 58 | , comments : List Comment 59 | , url : Maybe String 60 | , deleted : Bool 61 | , dead : Bool 62 | , poll : Maybe (List PollOption) 63 | } 64 | 65 | 66 | type alias Comment = 67 | { id : String 68 | , text : String 69 | , score : Int 70 | , user : String 71 | , time : Int 72 | , kids : Kids 73 | , deleted : Bool 74 | , dead : Bool 75 | } 76 | 77 | 78 | type Kids 79 | = Kids (List Comment) 80 | 81 | 82 | type Collapsible 83 | = Open 84 | | Closed 85 | 86 | 87 | type alias User = 88 | { id : String 89 | , created : Int 90 | , about : Maybe String 91 | } 92 | 93 | 94 | type StoryType 95 | = Top 96 | | Newest 97 | | Show 98 | | Ask 99 | | Jobs 100 | 101 | 102 | type alias PollOption = 103 | { text : String 104 | , score : Int 105 | } 106 | -------------------------------------------------------------------------------- /src/Update.elm: -------------------------------------------------------------------------------- 1 | module Update exposing (init, onLocationChange, subscriptions, update) 2 | 3 | import Api 4 | import Erl as Url 5 | import Http 6 | import Navigation 7 | import RemoteData exposing (RemoteData(..), WebData) 8 | import Set 9 | import Task 10 | import Time 11 | import Types exposing (..) 12 | 13 | 14 | onLocationChange : Navigation.Location -> Msg 15 | onLocationChange loc = 16 | RouteUpdate (routeByLocation loc) 17 | 18 | 19 | toggleCollapseHelper : 20 | comparable 21 | -> Collapsible 22 | -> Set.Set comparable 23 | -> Set.Set comparable 24 | toggleCollapseHelper id state comments = 25 | let 26 | operation = 27 | case state of 28 | Open -> 29 | Set.insert 30 | 31 | Closed -> 32 | Set.remove 33 | in 34 | operation id comments 35 | 36 | 37 | init : Navigation.Location -> ( Model, Cmd Msg ) 38 | init location = 39 | let 40 | currentTime = 41 | Task.perform CurrentTime Time.now 42 | 43 | currentRoute = 44 | routeByLocation location 45 | 46 | initialModel = 47 | { now = 0, route = currentRoute } 48 | 49 | cmds = 50 | cmdsForRoute currentRoute 51 | in 52 | initialModel ! (currentTime :: cmds) 53 | 54 | 55 | createRequest : (WebData a -> Msg) -> Http.Request a -> List (Cmd Msg) 56 | createRequest msg apiCall = 57 | apiCall 58 | |> RemoteData.sendRequest 59 | |> Cmd.map msg 60 | |> List.singleton 61 | 62 | 63 | fetchStories : StoryType -> Int -> List (Cmd Msg) 64 | fetchStories storyType page = 65 | createRequest (FetchHNStories storyType) <| 66 | Api.fetchStories storyType ((page - 1) * 30) 67 | 68 | 69 | cmdsForRoute : Route -> List (Cmd Msg) 70 | cmdsForRoute route = 71 | case route of 72 | StoriesPageRoute storyType { page } -> 73 | fetchStories storyType page 74 | 75 | StoryRoute { id } -> 76 | createRequest FetchHNStory <| 77 | Api.fetchStory id 78 | 79 | UserRoute { id } -> 80 | createRequest FetchHNUser <| 81 | Api.fetchUser id 82 | 83 | NotFoundRoute -> 84 | [] 85 | 86 | 87 | routeByLocation : Navigation.Location -> Route 88 | routeByLocation loc = 89 | let 90 | parsed = 91 | Url.parse loc.href 92 | 93 | storiesRoute storyType = 94 | StoriesPageRoute storyType 95 | { page = getPage parsed.query 96 | , stories = Loading 97 | } 98 | in 99 | case parsed.path of 100 | [] -> 101 | storiesRoute Top 102 | 103 | "newest" :: [] -> 104 | storiesRoute Newest 105 | 106 | "show" :: [] -> 107 | storiesRoute Show 108 | 109 | "ask" :: [] -> 110 | storiesRoute Ask 111 | 112 | "jobs" :: [] -> 113 | storiesRoute Jobs 114 | 115 | "story" :: id :: [] -> 116 | StoryRoute 117 | { id = id 118 | , story = Loading 119 | , collapsedComments = Set.empty 120 | } 121 | 122 | "user" :: id :: [] -> 123 | UserRoute 124 | { id = id 125 | , user = Loading 126 | } 127 | 128 | _ -> 129 | NotFoundRoute 130 | 131 | 132 | getPage : Url.Query -> Int 133 | getPage query = 134 | query 135 | |> List.filterMap getPageHelper 136 | |> List.head 137 | |> Maybe.withDefault 1 138 | 139 | 140 | getPageHelper : ( String, String ) -> Maybe Int 141 | getPageHelper ( key, val ) = 142 | if key == "page" then 143 | Result.toMaybe (String.toInt val) 144 | else 145 | Nothing 146 | 147 | 148 | update : Msg -> Model -> ( Model, Cmd Msg ) 149 | update msg model = 150 | case msg of 151 | NoOp -> 152 | model ! [] 153 | 154 | CurrentTime time -> 155 | { model | now = time } ! [] 156 | 157 | RouteUpdate route -> 158 | { model | route = route } ! cmdsForRoute route 159 | 160 | FetchHNStories storyType response -> 161 | case model.route of 162 | StoriesPageRoute _ data -> 163 | let 164 | newRoute = 165 | StoriesPageRoute storyType { data | stories = response } 166 | in 167 | { model | route = newRoute } ! [] 168 | 169 | _ -> 170 | model ! [] 171 | 172 | FetchHNStory response -> 173 | case model.route of 174 | StoryRoute data -> 175 | let 176 | newRoute = 177 | StoryRoute { data | story = response } 178 | in 179 | { model | route = newRoute } ! [] 180 | 181 | _ -> 182 | model ! [] 183 | 184 | FetchHNUser response -> 185 | case model.route of 186 | UserRoute data -> 187 | let 188 | newRoute = 189 | UserRoute { data | user = response } 190 | in 191 | { model | route = newRoute } ! [] 192 | 193 | _ -> 194 | model ! [] 195 | 196 | Go path -> 197 | ( model, Navigation.newUrl path ) 198 | 199 | ToggleCollapse id collapsed -> 200 | case model.route of 201 | StoryRoute data -> 202 | let 203 | newCollapsed = 204 | toggleCollapseHelper id collapsed data.collapsedComments 205 | 206 | newRoute = 207 | StoryRoute { data | collapsedComments = newCollapsed } 208 | in 209 | { model | route = newRoute } ! [] 210 | 211 | _ -> 212 | model ! [] 213 | 214 | 215 | subscriptions : Model -> Sub Msg 216 | subscriptions model = 217 | Sub.none 218 | -------------------------------------------------------------------------------- /src/UserProfile.elm: -------------------------------------------------------------------------------- 1 | module UserProfile exposing (page) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (class, href) 5 | import Maybe.Extra as Maybe 6 | import Time exposing (Time) 7 | import Types exposing (..) 8 | import Utils exposing (formatTime, innerHtml) 9 | 10 | 11 | page : Time -> User -> Html Msg 12 | page now user = 13 | let 14 | about = 15 | Maybe.unwrap [ text "" ] aboutSection user.about 16 | |> p [] 17 | in 18 | div [ class "Comment__text UserProfile" ] 19 | [ h4 [] [ text user.id ] 20 | , dl [] <| 21 | [ dt [] [ text "Created" ] 22 | , dd [] [ text <| formatTime now user.created ] 23 | , about 24 | , span [] (links user.id) 25 | ] 26 | ] 27 | 28 | 29 | aboutSection : String -> List (Html Msg) 30 | aboutSection about = 31 | [ dt [] [ text "About" ] 32 | , dd [ innerHtml about ] [] 33 | ] 34 | 35 | 36 | links : String -> List (Html Msg) 37 | links id = 38 | [ dt [] [] 39 | , dd [] <| 40 | List.map (makeLink id) 41 | [ ( "submitted", "stories" ) 42 | , ( "threads", "comments" ) 43 | , ( "favorites", "favorites" ) 44 | ] 45 | ] 46 | 47 | 48 | makeLink : String -> ( String, String ) -> Html Msg 49 | makeLink id ( slug, title ) = 50 | let 51 | url = 52 | String.join "" [ "https://news.ycombinator.com/", slug, "?id=", id ] 53 | in 54 | div [] [ a [ href url ] [ text title ] ] 55 | -------------------------------------------------------------------------------- /src/Utils.elm: -------------------------------------------------------------------------------- 1 | module Utils exposing (..) 2 | 3 | import Date 4 | import Date.Distance 5 | import Html 6 | import Html.Attributes 7 | import Html.Events 8 | import Json.Decode 9 | import Json.Encode 10 | import Maybe.Extra as Maybe 11 | import Time exposing (Time) 12 | import Types exposing (..) 13 | 14 | 15 | formatTime : Time -> Int -> String 16 | formatTime nowMs ms = 17 | let 18 | now = 19 | Date.fromTime nowMs 20 | 21 | date = 22 | Date.fromTime <| toFloat ms * 1000 23 | in 24 | Date.Distance.inWords date now ++ " ago" 25 | 26 | 27 | innerHtml : String -> Html.Attribute Msg 28 | innerHtml content = 29 | Html.Attributes.property "innerHTML" <| Json.Encode.string content 30 | 31 | 32 | maybeRender : (a -> Html.Html b) -> Maybe a -> Html.Html b 33 | maybeRender fn maybeValue = 34 | Maybe.unwrap (Html.text "") fn maybeValue 35 | 36 | 37 | href : String -> List (Html.Attribute Msg) 38 | href path = 39 | [ Html.Attributes.href path 40 | , Html.Events.onWithOptions 41 | "click" 42 | { stopPropagation = True, preventDefault = True } 43 | (Json.Decode.map (\_ -> Go path) Json.Decode.value) 44 | ] 45 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require('./main.css'); 2 | 3 | var Elm = require('./Main.elm'); 4 | var root = document.getElementById('root'); 5 | Elm.Main.embed(root); 6 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | margin: 0; 4 | } 5 | form { 6 | margin: 0; 7 | } 8 | img { 9 | vertical-align: text-bottom; 10 | } 11 | pre { 12 | white-space: pre-wrap; 13 | } 14 | span.control { 15 | cursor: pointer; 16 | } 17 | span.control:hover { 18 | text-decoration: underline; 19 | } 20 | 21 | /* From https://github.com/tobiasahlin/SpinKit */ 22 | .Spinner { 23 | } 24 | .Spinner > div { 25 | background-color: #666; 26 | border-radius: 100%; 27 | display: inline-block; 28 | -webkit-animation: bouncedelay 1.4s infinite ease-in-out; 29 | animation: bouncedelay 1.4s infinite ease-in-out; 30 | /* Prevent first frame from flickering when animation starts */ 31 | -webkit-animation-fill-mode: both; 32 | animation-fill-mode: both; 33 | } 34 | .Spinner .bounce1 { 35 | -webkit-animation-delay: -0.32s; 36 | animation-delay: -0.32s; 37 | } 38 | .Spinner .bounce2 { 39 | -webkit-animation-delay: -0.16s; 40 | animation-delay: -0.16s; 41 | } 42 | @-webkit-keyframes bouncedelay { 43 | 0%, 80%, 100% { -webkit-transform: scale(0.0) } 44 | 40% { -webkit-transform: scale(1.0) } 45 | } 46 | @keyframes bouncedelay { 47 | 0%, 80%, 100% { 48 | transform: scale(0.0); 49 | -webkit-transform: scale(0.0); 50 | } 40% { 51 | transform: scale(1.0); 52 | -webkit-transform: scale(1.0); 53 | } 54 | } 55 | 56 | .App__wrap { 57 | width: 90%; 58 | max-width: 1280px; 59 | margin: 8px auto; 60 | color: #000; 61 | background-color: #f5f5f5; 62 | font-size: 13.3333px; 63 | font-family: Verdana, Geneva, sans-serif; 64 | } 65 | .App__header { 66 | color: #00d8ff; 67 | background-color: #222; 68 | padding: 6px; 69 | line-height: 18px; 70 | vertical-align: middle; 71 | position: relative; 72 | } 73 | .App__settings { 74 | position: absolute; 75 | top: 6px; 76 | right: 10px; 77 | cursor: pointer; 78 | } 79 | .Settings { 80 | box-sizing: border-box; 81 | padding: .75em; 82 | position: absolute; 83 | width: 36%%; 84 | background-color: inherit; 85 | right: 0; 86 | border-bottom-left-radius: 1em; 87 | border-bottom-right-radius: 1em; 88 | } 89 | .Settings__setting td:first-child { 90 | text-align: right; 91 | } 92 | .Settings p { 93 | color: #fff; 94 | } 95 | .Settings div:last-child > p:last-child { 96 | margin-bottom: 0; 97 | } 98 | .App__header img { 99 | border: 1px solid #00d8ff; 100 | margin-right: .25em; 101 | } 102 | .App__header a { 103 | color: inherit; 104 | text-decoration: none; 105 | } 106 | .App__header a.active { 107 | color: #fff; 108 | } 109 | .App__homelink { 110 | text-decoration: none; 111 | font-weight: bold; 112 | color: #00d8ff !important; 113 | margin-right: .75em; 114 | } 115 | .App__homelink.active { 116 | color: #fff !important; 117 | } 118 | .App__content { 119 | overflow-wrap: break-word; 120 | word-wrap: break-word; 121 | } 122 | .App__footer { 123 | margin-top: 1em; 124 | border-top: 1px solid #e7e7e7; 125 | text-align: center; 126 | color: #333; 127 | padding: 6px 0; 128 | } 129 | .App__footer a { 130 | color: inherit; 131 | text-decoration: underline; 132 | } 133 | 134 | .Items__list { 135 | padding-left: 3em; 136 | padding-right: 1.25em; 137 | margin-top: 1em; 138 | margin-bottom: .5em; 139 | } 140 | .ListItem { 141 | margin-bottom: 16px; 142 | } 143 | .ListItem--loading { 144 | min-height: 34px; 145 | } 146 | 147 | .Updates--loading { 148 | padding: 1em 1.25em 0 1.25em; 149 | margin-bottom: 1em; 150 | } 151 | .Updates__notice { 152 | padding: 0 1.25em; 153 | } 154 | .Updates .Comment { 155 | margin-bottom: .75em; 156 | } 157 | 158 | .Paginator { 159 | margin-left: 3em; 160 | padding: .5em 0; 161 | } 162 | .Paginator a { 163 | font-weight: bold; 164 | color: #000; 165 | text-decoration: none; 166 | } 167 | .Paginator a:hover { 168 | text-decoration: underline; 169 | } 170 | 171 | .Item__content, 172 | .Item--loading { 173 | padding: 1em 1.25em 0 1.25em; 174 | margin-bottom: 1em; 175 | } 176 | .Item__title { 177 | color: #666; 178 | font-size:18px; 179 | } 180 | .Item__title a { 181 | text-decoration: none; 182 | color: #000; 183 | } 184 | .Item__title a:hover { 185 | text-decoration: underline; 186 | } 187 | .Item__title a:visited { 188 | color: #666; 189 | } 190 | .Item__meta { 191 | color: #666; 192 | } 193 | .Item > .Item__meta { 194 | margin-bottom: 1em; 195 | } 196 | .Item__meta a { 197 | text-decoration: none; 198 | color: inherit; 199 | } 200 | .Item__meta a:hover { 201 | text-decoration: underline; 202 | } 203 | .Item__meta em { 204 | font-style: normal; 205 | background-color: #ffffde; 206 | color: #000; 207 | } 208 | .Item__by a, .ListItem__newcomments a { 209 | font-weight: bold; 210 | } 211 | .Item__text { 212 | margin-top: 1em; 213 | } 214 | .Item__poll { 215 | margin-top: 1em; 216 | padding-left: 2.5em; 217 | } 218 | 219 | .PollOption { 220 | margin-bottom: 10px; 221 | } 222 | .PollOption__score { 223 | color: #666; 224 | } 225 | 226 | .PermalinkedComment > .Comment__content { 227 | margin-bottom: 1em; 228 | } 229 | .Comment { 230 | } 231 | .Comment--new > .Comment__content { 232 | background-color: #ffffde; 233 | } 234 | /* Highlights a comment and its descendants on hover -- too distracting? 235 | .Comment:hover > .Comment__content { 236 | background-color: #fff; 237 | } 238 | */ 239 | .Comment__meta { 240 | color: #666; 241 | margin-bottom: .5em 242 | } 243 | .Comment__meta a { 244 | text-decoration: none; 245 | color: inherit; 246 | } 247 | .Comment__meta a:hover { 248 | text-decoration: underline; 249 | } 250 | .Comment__meta em { 251 | font-style: normal; 252 | background-color: #ffffde; 253 | color: #000; 254 | } 255 | .Comment__user { 256 | font-weight: bold; 257 | } 258 | .Comment__content, .Comment--loading { 259 | padding-right: 1.25em; 260 | padding-top: .65em; 261 | padding-bottom: .65em; 262 | } 263 | .Comment--level0 .Comment__content, .Comment--level0.Comment--loading { padding-left: 1.25em; } 264 | .Comment--level1 .Comment__content, .Comment--level1.Comment--loading { padding-left: 3.75em; } 265 | .Comment--level2 .Comment__content, .Comment--level2.Comment--loading { padding-left: 6.25em; } 266 | .Comment--level3 .Comment__content, .Comment--level3.Comment--loading { padding-left: 8.75em; } 267 | .Comment--level4 .Comment__content, .Comment--level4.Comment--loading { padding-left: 11.25em; } 268 | .Comment--level5 .Comment__content, .Comment--level5.Comment--loading { padding-left: 13.75em; } 269 | .Comment--level6 .Comment__content, .Comment--level6.Comment--loading { padding-left: 16.25em; } 270 | .Comment--level7 .Comment__content, .Comment--level7.Comment--loading { padding-left: 18.75em; } 271 | .Comment--level8 .Comment__content, .Comment--level8.Comment--loading { padding-left: 21.25em; } 272 | .Comment--level9 .Comment__content, .Comment--level9.Comment--loading { padding-left: 23.75em; } 273 | .Comment--level10 .Comment__content, .Comment--level10.Comment--loading { padding-left: 26.25em; } 274 | .Comment--level11 .Comment__content, .Comment--level11.Comment--loading { padding-left: 28.75em; } 275 | .Comment--level12 .Comment__content, .Comment--level12.Comment--loading { padding-left: 31.25em; } 276 | .Comment--level13 .Comment__content, .Comment--level13.Comment--loading { padding-left: 33.75em; } 277 | .Comment--level14 .Comment__content, .Comment--level14.Comment--loading { padding-left: 36.25em; } 278 | .Comment--level15 .Comment__content, .Comment--level15.Comment--loading { padding-left: 38.75em; } 279 | .Comment__kids { 280 | } 281 | .Comment__collapse { 282 | cursor: pointer; 283 | } 284 | .Comment--collapsed .Comment__text, 285 | .Comment--collapsed > .Comment__kids { 286 | display: none; 287 | } 288 | .Comment__text a { 289 | color: #000; 290 | } 291 | .Comment__text a:visited { 292 | color: #666; 293 | } 294 | .Comment__text p:last-child, .Comment__text pre:last-child { 295 | margin-bottom: 0; 296 | } 297 | .Comment--dead > .Comment__content > .Comment__text { 298 | color: #ddd !important; 299 | } 300 | .Comment--error .Comment__meta { 301 | color: #f33; 302 | } 303 | 304 | .UserProfile { 305 | padding-left: 1.25em; 306 | padding-top: 1em; 307 | } 308 | .UserProfile h4 { 309 | margin: 0 0 1em 0; 310 | } 311 | 312 | @media only screen and (max-width: 750px) and (min-width: 300px) { 313 | .App__wrap { 314 | width: 100%; 315 | margin: 0px auto; 316 | } 317 | 318 | /* Hide the App title homelink on narrow viewports */ 319 | .App__homelink { 320 | display: none; 321 | } 322 | 323 | /* Safari only fix to ensure Settings menu is full width */ 324 | _::-webkit-:not(:root:root), .Settings { 325 | width: 100%; 326 | } 327 | } 328 | 329 | /* Hide the Settings link on iPhone 5 */ 330 | @media (device-height: 568px) and (device-width: 320px) and (-webkit-min-device-pixel-ratio: 2) { 331 | .App__settings { 332 | display: none; 333 | } 334 | } 335 | --------------------------------------------------------------------------------