Bump part3 and part4
This commit is contained in:
460
advanced/part5/src/Article/Feed.elm
Normal file
460
advanced/part5/src/Article/Feed.elm
Normal file
@@ -0,0 +1,460 @@
|
||||
module Article.Feed
|
||||
exposing
|
||||
( Model
|
||||
, Msg
|
||||
, init
|
||||
, selectTag
|
||||
, update
|
||||
, viewArticles
|
||||
, viewFeedSources
|
||||
)
|
||||
|
||||
import Api
|
||||
import Article exposing (Article, Preview)
|
||||
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||
import Article.Slug as ArticleSlug exposing (Slug)
|
||||
import Article.Tag as Tag exposing (Tag)
|
||||
import Author
|
||||
import Avatar exposing (Avatar)
|
||||
import Browser.Dom as Dom
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src)
|
||||
import Html.Events exposing (onClick)
|
||||
import Http
|
||||
import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Decode.Pipeline exposing (required)
|
||||
import Page
|
||||
import PaginatedList exposing (PaginatedList)
|
||||
import Profile
|
||||
import Route exposing (Route)
|
||||
import Session exposing (Session)
|
||||
import Task exposing (Task)
|
||||
import Time
|
||||
import Timestamp
|
||||
import Username exposing (Username)
|
||||
import Viewer exposing (Viewer)
|
||||
import Viewer.Cred as Cred exposing (Cred)
|
||||
|
||||
|
||||
{-| NOTE: This module has its own Model, view, and update. This is not normal!
|
||||
If you find yourself doing this often, please watch <https://www.youtube.com/watch?v=DoA4Txr4GUs>
|
||||
|
||||
This is the reusable Article Feed that appears on both the Home page as well as
|
||||
on the Profile page. There's a lot of logic here, so it's more convenient to use
|
||||
the heavyweight approach of giving this its own Model, view, and update.
|
||||
|
||||
This means callers must use Html.map and Cmd.map to use this thing, but in
|
||||
this case that's totally worth it because of the amount of logic wrapped up
|
||||
in this thing.
|
||||
|
||||
For every other reusable view in this application, this API would be totally
|
||||
overkill, so we use simpler APIs instead.
|
||||
|
||||
-}
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type Model
|
||||
= Model InternalModel
|
||||
|
||||
|
||||
{-| This should not be exposed! We want to benefit from the guarantee that only
|
||||
this module can create or alter this model. This way if it ever ends up in
|
||||
a surprising state, we know exactly where to look: this module.
|
||||
-}
|
||||
type alias InternalModel =
|
||||
{ session : Session
|
||||
, errors : List String
|
||||
, articles : PaginatedList (Article Preview)
|
||||
, sources : FeedSources
|
||||
, isLoading : Bool
|
||||
}
|
||||
|
||||
|
||||
init : Session -> FeedSources -> Task Http.Error Model
|
||||
init session sources =
|
||||
let
|
||||
fromArticles articles =
|
||||
Model
|
||||
{ session = session
|
||||
, errors = []
|
||||
, articles = articles
|
||||
, sources = sources
|
||||
, isLoading = False
|
||||
}
|
||||
in
|
||||
FeedSources.selected sources
|
||||
|> fetch (Session.cred session) 1
|
||||
|> Task.map fromArticles
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
viewArticles : Time.Zone -> Model -> List (Html Msg)
|
||||
viewArticles timeZone (Model { articles, sources, session }) =
|
||||
let
|
||||
maybeCred =
|
||||
Session.cred session
|
||||
|
||||
articlesHtml =
|
||||
PaginatedList.values articles
|
||||
|> List.map (viewPreview maybeCred timeZone)
|
||||
|
||||
feedSource =
|
||||
FeedSources.selected sources
|
||||
|
||||
pagination =
|
||||
viewPaginatedList articles (limit feedSource)
|
||||
in
|
||||
List.append articlesHtml [ pagination ]
|
||||
|
||||
|
||||
{-| 👉 TODO Move this logic into PaginatedList.view and make it reusable,
|
||||
so we can use it on other pages too!
|
||||
💡 HINT: Make `PaginatedList.view` return `Html msg` instead of `Html Msg`. (The function will need to accept an extra argument for this to work.)
|
||||
-}
|
||||
viewPaginatedList : PaginatedList a -> Int -> Html Msg
|
||||
viewPaginatedList paginatedList resultsPerPage =
|
||||
let
|
||||
totalPages =
|
||||
ceiling (toFloat (PaginatedList.total paginatedList) / toFloat resultsPerPage)
|
||||
|
||||
activePage =
|
||||
PaginatedList.page paginatedList
|
||||
|
||||
viewPageLink currentPage =
|
||||
pageLink currentPage (currentPage == activePage)
|
||||
in
|
||||
if totalPages > 1 then
|
||||
List.range 1 totalPages
|
||||
|> List.map viewPageLink
|
||||
|> ul [ class "pagination" ]
|
||||
|
||||
else
|
||||
Html.text ""
|
||||
|
||||
|
||||
pageLink : Int -> Bool -> Html Msg
|
||||
pageLink targetPage isActive =
|
||||
li [ classList [ ( "page-item", True ), ( "active", isActive ) ] ]
|
||||
[ a
|
||||
[ class "page-link"
|
||||
, onClick (ClickedFeedPage targetPage)
|
||||
|
||||
-- The RealWorld CSS requires an href to work properly.
|
||||
, href ""
|
||||
]
|
||||
[ text (String.fromInt targetPage) ]
|
||||
]
|
||||
|
||||
|
||||
viewPreview : Maybe Cred -> Time.Zone -> Article Preview -> Html Msg
|
||||
viewPreview maybeCred timeZone article =
|
||||
let
|
||||
slug =
|
||||
Article.slug article
|
||||
|
||||
{ title, description, createdAt } =
|
||||
Article.metadata article
|
||||
|
||||
author =
|
||||
Article.author article
|
||||
|
||||
profile =
|
||||
Author.profile author
|
||||
|
||||
username =
|
||||
Author.username author
|
||||
|
||||
faveButton =
|
||||
case maybeCred of
|
||||
Just cred ->
|
||||
let
|
||||
{ favoritesCount, favorited } =
|
||||
Article.metadata article
|
||||
|
||||
viewButton =
|
||||
if favorited then
|
||||
Article.unfavoriteButton cred (ClickedUnfavorite cred slug)
|
||||
|
||||
else
|
||||
Article.favoriteButton cred (ClickedFavorite cred slug)
|
||||
in
|
||||
viewButton [ class "pull-xs-right" ]
|
||||
[ text (" " ++ String.fromInt favoritesCount) ]
|
||||
|
||||
Nothing ->
|
||||
text ""
|
||||
in
|
||||
div [ class "article-preview" ]
|
||||
[ div [ class "article-meta" ]
|
||||
[ a [ Route.href (Route.Profile username) ]
|
||||
[ img [ Avatar.src (Profile.avatar profile) ] [] ]
|
||||
, div [ class "info" ]
|
||||
[ Author.view username
|
||||
, Timestamp.view timeZone createdAt
|
||||
]
|
||||
, faveButton
|
||||
]
|
||||
, a [ class "preview-link", Route.href (Route.Article (Article.slug article)) ]
|
||||
[ h1 [] [ text title ]
|
||||
, p [] [ text description ]
|
||||
, span [] [ text "Read more..." ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewFeedSources : Model -> Html Msg
|
||||
viewFeedSources (Model { sources, isLoading, errors }) =
|
||||
let
|
||||
errorsHtml =
|
||||
Page.viewErrors ClickedDismissErrors errors
|
||||
in
|
||||
ul [ class "nav nav-pills outline-active" ] <|
|
||||
List.concat
|
||||
[ List.map (viewFeedSource False) (FeedSources.before sources)
|
||||
, [ viewFeedSource True (FeedSources.selected sources) ]
|
||||
, List.map (viewFeedSource False) (FeedSources.after sources)
|
||||
, [ errorsHtml ]
|
||||
]
|
||||
|
||||
|
||||
viewFeedSource : Bool -> Source -> Html Msg
|
||||
viewFeedSource isSelected source =
|
||||
li [ class "nav-item" ]
|
||||
[ a
|
||||
[ classList [ ( "nav-link", True ), ( "active", isSelected ) ]
|
||||
, onClick (ClickedFeedSource source)
|
||||
|
||||
-- The RealWorld CSS requires an href to work properly.
|
||||
, href ""
|
||||
]
|
||||
[ text (sourceName source) ]
|
||||
]
|
||||
|
||||
|
||||
selectTag : Maybe Cred -> Tag -> Cmd Msg
|
||||
selectTag maybeCred tag =
|
||||
let
|
||||
source =
|
||||
TagFeed tag
|
||||
in
|
||||
fetch maybeCred 1 source
|
||||
|> Task.attempt (CompletedFeedLoad source)
|
||||
|
||||
|
||||
sourceName : Source -> String
|
||||
sourceName source =
|
||||
case source of
|
||||
YourFeed _ ->
|
||||
"Your Feed"
|
||||
|
||||
GlobalFeed ->
|
||||
"Global Feed"
|
||||
|
||||
TagFeed tagName ->
|
||||
"#" ++ Tag.toString tagName
|
||||
|
||||
FavoritedFeed username ->
|
||||
"Favorited Articles"
|
||||
|
||||
AuthorFeed username ->
|
||||
"My Articles"
|
||||
|
||||
|
||||
limit : Source -> Int
|
||||
limit feedSource =
|
||||
case feedSource of
|
||||
YourFeed _ ->
|
||||
10
|
||||
|
||||
GlobalFeed ->
|
||||
10
|
||||
|
||||
TagFeed tagName ->
|
||||
10
|
||||
|
||||
FavoritedFeed username ->
|
||||
5
|
||||
|
||||
AuthorFeed username ->
|
||||
5
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= ClickedDismissErrors
|
||||
| ClickedFavorite Cred Slug
|
||||
| ClickedUnfavorite Cred Slug
|
||||
| ClickedFeedPage Int
|
||||
| ClickedFeedSource Source
|
||||
| CompletedFavorite (Result Http.Error (Article Preview))
|
||||
| CompletedFeedLoad Source (Result Http.Error (PaginatedList (Article Preview)))
|
||||
|
||||
|
||||
update : Maybe Cred -> Msg -> Model -> ( Model, Cmd Msg )
|
||||
update maybeCred msg (Model model) =
|
||||
case msg of
|
||||
ClickedDismissErrors ->
|
||||
( Model { model | errors = [] }, Cmd.none )
|
||||
|
||||
ClickedFeedSource source ->
|
||||
( Model { model | isLoading = True }
|
||||
, source
|
||||
|> fetch maybeCred 1
|
||||
|> Task.attempt (CompletedFeedLoad source)
|
||||
)
|
||||
|
||||
CompletedFeedLoad source (Ok articles) ->
|
||||
( Model
|
||||
{ model
|
||||
| articles = articles
|
||||
, sources = FeedSources.select source model.sources
|
||||
, isLoading = False
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
CompletedFeedLoad _ (Err error) ->
|
||||
( Model
|
||||
{ model
|
||||
| errors = Api.addServerError model.errors
|
||||
, isLoading = False
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ClickedFavorite cred slug ->
|
||||
fave Article.favorite cred slug model
|
||||
|
||||
ClickedUnfavorite cred slug ->
|
||||
fave Article.unfavorite cred slug model
|
||||
|
||||
CompletedFavorite (Ok article) ->
|
||||
( Model { model | articles = PaginatedList.map (replaceArticle article) model.articles }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
CompletedFavorite (Err error) ->
|
||||
( Model { model | errors = Api.addServerError model.errors }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ClickedFeedPage page ->
|
||||
let
|
||||
source =
|
||||
FeedSources.selected model.sources
|
||||
in
|
||||
( Model model
|
||||
, fetch maybeCred page source
|
||||
|> Task.andThen (\articles -> Task.map (\_ -> articles) scrollToTop)
|
||||
|> Task.attempt (CompletedFeedLoad source)
|
||||
)
|
||||
|
||||
|
||||
scrollToTop : Task x ()
|
||||
scrollToTop =
|
||||
Dom.setViewport 0 0
|
||||
-- It's not worth showing the user anything special if scrolling fails.
|
||||
-- If anything, we'd log this to an error recording service.
|
||||
|> Task.onError (\_ -> Task.succeed ())
|
||||
|
||||
|
||||
fetch : Maybe Cred -> Int -> Source -> Task Http.Error (PaginatedList (Article Preview))
|
||||
fetch maybeCred page feedSource =
|
||||
let
|
||||
articlesPerPage =
|
||||
limit feedSource
|
||||
|
||||
offset =
|
||||
(page - 1) * articlesPerPage
|
||||
|
||||
params =
|
||||
[ ( "limit", String.fromInt articlesPerPage )
|
||||
, ( "offset", String.fromInt offset )
|
||||
]
|
||||
in
|
||||
Task.map (PaginatedList.mapPage (\_ -> page)) <|
|
||||
case feedSource of
|
||||
YourFeed cred ->
|
||||
params
|
||||
|> buildFromQueryParams (Just cred) (Api.url [ "articles", "feed" ])
|
||||
|> Cred.addHeader cred
|
||||
|> HttpBuilder.toRequest
|
||||
|> Http.toTask
|
||||
|
||||
GlobalFeed ->
|
||||
list maybeCred params
|
||||
|
||||
TagFeed tagName ->
|
||||
list maybeCred (( "tag", Tag.toString tagName ) :: params)
|
||||
|
||||
FavoritedFeed username ->
|
||||
list maybeCred (( "favorited", Username.toString username ) :: params)
|
||||
|
||||
AuthorFeed username ->
|
||||
list maybeCred (( "author", Username.toString username ) :: params)
|
||||
|
||||
|
||||
list :
|
||||
Maybe Cred
|
||||
-> List ( String, String )
|
||||
-> Task Http.Error (PaginatedList (Article Preview))
|
||||
list maybeCred params =
|
||||
buildFromQueryParams maybeCred (Api.url [ "articles" ]) params
|
||||
|> Cred.addHeaderIfAvailable maybeCred
|
||||
|> HttpBuilder.toRequest
|
||||
|> Http.toTask
|
||||
|
||||
|
||||
replaceArticle : Article a -> Article a -> Article a
|
||||
replaceArticle newArticle oldArticle =
|
||||
if Article.slug newArticle == Article.slug oldArticle then
|
||||
newArticle
|
||||
|
||||
else
|
||||
oldArticle
|
||||
|
||||
|
||||
|
||||
-- SERIALIZATION
|
||||
|
||||
|
||||
decoder : Maybe Cred -> Decoder (PaginatedList (Article Preview))
|
||||
decoder maybeCred =
|
||||
Decode.succeed PaginatedList.fromList
|
||||
|> required "articlesCount" Decode.int
|
||||
|> required "articles" (Decode.list (Article.previewDecoder maybeCred))
|
||||
|
||||
|
||||
|
||||
-- REQUEST
|
||||
|
||||
|
||||
buildFromQueryParams : Maybe Cred -> String -> List ( String, String ) -> RequestBuilder (PaginatedList (Article Preview))
|
||||
buildFromQueryParams maybeCred url queryParams =
|
||||
HttpBuilder.get url
|
||||
|> withExpect (Http.expectJson (decoder maybeCred))
|
||||
|> withQueryParams queryParams
|
||||
|
||||
|
||||
|
||||
-- INTERNAL
|
||||
|
||||
|
||||
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> InternalModel -> ( Model, Cmd Msg )
|
||||
fave toRequest cred slug model =
|
||||
( Model model
|
||||
, toRequest slug cred
|
||||
|> Http.toTask
|
||||
|> Task.attempt CompletedFavorite
|
||||
)
|
||||
Reference in New Issue
Block a user