├── .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 | [](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 |
--------------------------------------------------------------------------------