├── .gitignore ├── Ports.elm ├── README.md ├── Main.elm ├── elm-package.json ├── index.html ├── package.json ├── View.elm └── Model.elm /.gitignore: -------------------------------------------------------------------------------- 1 | /app.js 2 | /build 3 | /npm-debug.log 4 | /node_modules 5 | /elm-stuff 6 | -------------------------------------------------------------------------------- /Ports.elm: -------------------------------------------------------------------------------- 1 | port module Ports exposing (saveAuthToken) 2 | 3 | 4 | port saveAuthToken : Maybe String -> Cmd msg 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | myshows 2 | ======= 3 | 4 | A simple [Elm app](https://n1k0.github.io/myshows/) to list and rate your favorite TV shows. 5 | 6 | [![](http://i.imgur.com/RA51Hd6.png)](https://n1k0.github.io/myshows/) 7 | 8 | Experimental: Remote storage on Kinto through a Firefox Account authentication. **Warning:** storage is reset everyday, expect losing your data. 9 | 10 | There's no way to share your list with friends just yet. 11 | -------------------------------------------------------------------------------- /Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Model exposing (Model, Msg(..), init, update) 4 | import View exposing (view) 5 | import Navigation 6 | 7 | 8 | -- TODO 9 | -- - ? Add dates: added_on, rated_on (seen) 10 | -- - kinto integration 11 | 12 | 13 | main : Program Model.Flags Model Msg 14 | main = 15 | Navigation.programWithFlags UrlChange 16 | { init = init 17 | , view = view 18 | , update = update 19 | , subscriptions = always Sub.none 20 | } 21 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "helpful summary of your project, less than 80 characters", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "." 8 | ], 9 | "exposed-modules": [], 10 | "dependencies": { 11 | "Kinto/elm-kinto": "3.0.0 <= v < 4.0.0", 12 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 13 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 14 | "elm-lang/http": "1.0.0 <= v < 2.0.0", 15 | "elm-lang/navigation": "2.0.1 <= v < 3.0.0", 16 | "rtfeldman/elm-validate": "1.1.3 <= v < 2.0.0" 17 | }, 18 | "elm-version": "0.18.0 <= v < 0.19.0" 19 | } 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | My shows 6 | 7 | 8 | 9 | 10 | 11 | 12 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myshows", 3 | "version": "0.0.1", 4 | "description": "Keep a list of your favorite shows and rate them.", 5 | "private": true, 6 | "scripts": { 7 | "build": "node_modules/.bin/elm-make Main.elm --output=build/app.js && cp index.html build/", 8 | "deploy": "npm run build && node_modules/.bin/gh-pages --dist build/", 9 | "live": "node_modules/.bin/elm-live Main.elm --output=app.js --debug", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/n1k0/myshows.git" 15 | }, 16 | "keywords": [ 17 | "elm", 18 | "shows", 19 | "tvshows" 20 | ], 21 | "author": "Nicolas Perriault ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/n1k0/myshows/issues" 25 | }, 26 | "homepage": "https://github.com/n1k0/myshows#readme", 27 | "devDependencies": { 28 | "gh-pages": "^0.12.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (view) 2 | 3 | import Set 4 | import Html exposing (Html, Attribute) 5 | import Html.Attributes as Attr 6 | import Html.Events as Events 7 | import Json.Decode as Decode 8 | import Model exposing (..) 9 | 10 | 11 | maxStars : Int 12 | maxStars = 13 | 5 14 | 15 | 16 | maybeAsBool : Maybe a -> Bool 17 | maybeAsBool x = 18 | case x of 19 | Nothing -> 20 | False 21 | 22 | Just _ -> 23 | True 24 | 25 | 26 | htmlSpace : Html Msg 27 | htmlSpace = 28 | Html.text " " 29 | 30 | 31 | icon : String -> Html Msg 32 | icon kind = 33 | Html.i [ Attr.class <| "glyphicon glyphicon-" ++ kind ] [] 34 | 35 | 36 | {-| Custom onClick implementation with preventDefault enabled 37 | -} 38 | onClick_ : msg -> Attribute msg 39 | onClick_ msg = 40 | Events.onWithOptions 41 | "click" 42 | { preventDefault = True, stopPropagation = True } 43 | (Decode.succeed msg) 44 | 45 | 46 | starLink : Show -> Int -> Html Msg 47 | starLink show rank = 48 | let 49 | showRating = 50 | Maybe.withDefault 0 show.rating 51 | 52 | star = 53 | if rank > showRating then 54 | icon "star-empty" 55 | else 56 | icon "star" 57 | in 58 | Html.a 59 | [ Attr.href "" 60 | , Attr.style [ ( "font-size", "1.2em" ) ] 61 | , onClick_ <| RateShow show.title rank 62 | ] 63 | [ star ] 64 | 65 | 66 | ratingStars : Show -> Html Msg 67 | ratingStars show = 68 | Html.span [] 69 | (List.range 1 maxStars |> List.map (\rank -> starLink show rank)) 70 | 71 | 72 | genreLabel : String -> Html Msg 73 | genreLabel genre = 74 | Html.a 75 | [ Attr.href "" 76 | , onClick_ <| RefineGenre genre 77 | , Attr.class "badge" 78 | , Attr.style [ ( "margin", "0 .2em" ) ] 79 | ] 80 | [ Html.text genre ] 81 | 82 | 83 | showView : Show -> Html Msg 84 | showView show = 85 | Html.div [ Attr.class "panel panel-default" ] 86 | [ Html.div 87 | [ Attr.class "panel-heading" 88 | , Attr.style [ ( "padding", "10px 15px 7px 15px" ) ] 89 | ] 90 | [ Html.div [ Attr.class "row" ] 91 | [ Html.strong [ Attr.class "col-sm-6" ] 92 | [ if maybeAsBool show.rating then 93 | icon "eye-open" 94 | else 95 | icon "eye-close" 96 | , Html.span [ Attr.style [ ( "margin-left", ".5em" ) ] ] 97 | [ Html.text show.title ] 98 | ] 99 | , Html.div [ Attr.class "col-sm-6 text-right" ] <| 100 | List.intersperse htmlSpace 101 | [ ratingStars show 102 | , Html.a 103 | [ Attr.href "" 104 | , Attr.style [ ( "margin", "0 .5em" ) ] 105 | , onClick_ <| DeleteShow show 106 | ] 107 | [ icon "trash" ] 108 | , Html.a 109 | [ Attr.href "" 110 | , onClick_ <| EditShow show 111 | ] 112 | [ icon "pencil" ] 113 | ] 114 | ] 115 | ] 116 | , Html.div [ Attr.class "panel-body" ] 117 | [ Html.text <| Maybe.withDefault "No description available." show.description ] 118 | , if List.length show.genres > 0 then 119 | Html.div 120 | [ Attr.class "panel-footer text-center" 121 | , Attr.style [ ( "padding", "7px 15px 4px 15px" ) ] 122 | ] 123 | (List.map genreLabel show.genres) 124 | else 125 | Html.text "" 126 | ] 127 | 128 | 129 | formErrorsView : List String -> Html msg 130 | formErrorsView errors = 131 | if List.length errors > 0 then 132 | Html.div [ Attr.class "alert alert-danger" ] 133 | [ Html.ul [ Attr.class "error" ] 134 | (List.map (\e -> Html.li [] [ Html.text e ]) errors) 135 | ] 136 | else 137 | Html.text "" 138 | 139 | 140 | formRow : String -> List (Html Msg) -> Html Msg 141 | formRow label children = 142 | let 143 | htmlLabel = 144 | Html.label [] [ Html.text label ] 145 | in 146 | Html.div [ Attr.class "form-group" ] <| htmlLabel :: children 147 | 148 | 149 | showForm : Model -> Html Msg 150 | showForm ({ formErrors, formEdit, formData } as model) = 151 | let 152 | ratingString = 153 | case formData.rating of 154 | Nothing -> 155 | "" 156 | 157 | Just n -> 158 | toString n 159 | 160 | label = 161 | case formEdit of 162 | Nothing -> 163 | "Add show" 164 | 165 | Just title -> 166 | "Update " ++ title 167 | in 168 | Html.form [ Events.onSubmit <| FormSubmit ] 169 | [ Html.h2 [] [ Html.text label ] 170 | , formErrorsView formErrors 171 | , formRow "Title" 172 | [ Html.input 173 | [ Events.onInput <| FormEvent << UpdateTitle 174 | , Attr.value formData.title 175 | , Attr.type_ "text" 176 | , Attr.class "form-control" 177 | , Attr.placeholder "Show title" 178 | ] 179 | [] 180 | ] 181 | , formRow "Description" 182 | [ Html.textarea 183 | [ Events.onInput <| FormEvent << UpdateDescription 184 | , Attr.value <| Maybe.withDefault "" formData.description 185 | , Attr.class "form-control" 186 | , Attr.placeholder "Description" 187 | , Attr.rows 3 188 | ] 189 | [] 190 | ] 191 | , formRow "Genres" 192 | [ Html.input 193 | [ Events.onInput <| FormEvent << UpdateGenres 194 | , Attr.value <| String.join ", " formData.genres 195 | , Attr.type_ "text" 196 | , Attr.class "form-control" 197 | , Attr.placeholder "Comma separated, eg.: drama, action" 198 | ] 199 | [] 200 | ] 201 | , formRow "Rating" 202 | [ Html.input 203 | [ Events.onInput <| FormEvent << UpdateRating 204 | , Attr.value ratingString 205 | , Attr.type_ "number" 206 | , Attr.class "form-control" 207 | , Attr.min "1" 208 | , Attr.max (toString maxStars) 209 | , Attr.placeholder "Rating" 210 | ] 211 | [] 212 | ] 213 | , Html.p [] 214 | [ Html.button [ Attr.class "btn btn-primary" ] 215 | [ Html.text <| label ] 216 | ] 217 | ] 218 | 219 | 220 | sortLink : OrderBy -> OrderBy -> Html Msg 221 | sortLink orderBy current = 222 | if current == orderBy then 223 | Html.ins [] [ Html.text (toString orderBy) ] 224 | else 225 | Html.a [ Attr.href "", onClick_ <| SetOrderBy orderBy ] 226 | [ Html.text (toString orderBy) ] 227 | 228 | 229 | sortLinks : Model -> Html Msg 230 | sortLinks model = 231 | Html.p [] <| 232 | List.intersperse htmlSpace 233 | [ Html.text "Sort by" 234 | , sortLink TitleAsc model.currentOrderBy 235 | , Html.text "|" 236 | , sortLink RatingAsc model.currentOrderBy 237 | , Html.text "|" 238 | , sortLink RatingDesc model.currentOrderBy 239 | ] 240 | 241 | 242 | genreLink : Maybe Genre -> Genre -> Html Msg 243 | genreLink currentGenre genre = 244 | let 245 | bgColor = 246 | case currentGenre of 247 | Nothing -> 248 | "#555" 249 | 250 | Just current -> 251 | if current == genre then 252 | "#999" 253 | else 254 | "#555" 255 | in 256 | Html.a 257 | [ Attr.class "badge" 258 | , Attr.href "" 259 | , Attr.style [ ( "margin", "0 .2em" ), ( "background-color", bgColor ) ] 260 | , onClick_ <| RefineGenre genre 261 | ] 262 | [ Html.text genre ] 263 | 264 | 265 | genreLinks : Model -> Html Msg 266 | genreLinks { allGenres, currentGenre } = 267 | Html.p [] <| 268 | List.intersperse 269 | htmlSpace 270 | [ Html.text "Refine genre: " 271 | , Html.span [] <| List.map (genreLink currentGenre) (Set.toList <| allGenres) 272 | , Html.a [ Attr.href "", onClick_ ClearGenre ] [ Html.text "Clear" ] 273 | ] 274 | 275 | 276 | listView : Model -> Html Msg 277 | listView model = 278 | if List.length model.shows == 0 then 279 | Html.div [] 280 | [ Html.div [ Attr.class "alert alert-info" ] 281 | [ Html.text "No shows." ] 282 | , Html.p [ Attr.class "text-center" ] 283 | [ Html.button 284 | [ Attr.class "btn btn-primary" 285 | , onClick_ <| LoadShows dummyShows 286 | ] 287 | [ Html.text "Load sample shows" ] 288 | ] 289 | ] 290 | else 291 | let 292 | processedShows = 293 | model.shows 294 | |> sortShows model.currentOrderBy 295 | |> filterGenre model.currentGenre 296 | in 297 | Html.div [] 298 | [ sortLinks model 299 | , genreLinks model 300 | , Html.div [] <| List.map showView processedShows 301 | ] 302 | 303 | 304 | authView : Model -> Html Msg 305 | authView { authToken, appUrl, authUrl } = 306 | case authToken of 307 | Just _ -> 308 | Html.button 309 | [ Attr.class "btn btn-primary", Events.onClick Logout ] 310 | [ Html.text "Log out" ] 311 | 312 | Nothing -> 313 | Html.form [ Attr.method "post", Attr.action authUrl ] 314 | [ Html.h2 [] [ Html.text "Register" ] 315 | , formRow "Email" <| 316 | [ Html.input 317 | [ Attr.type_ "email" 318 | , Attr.class "form-control" 319 | , Attr.name "email" 320 | , Attr.placeholder "email@provider.tld" 321 | , Attr.required True 322 | ] 323 | [] 324 | ] 325 | , Html.input 326 | [ Attr.type_ "hidden" 327 | , Attr.name "redirect" 328 | , Attr.value <| appUrl ++ "#auth=" 329 | ] 330 | [] 331 | , Html.button 332 | [ Attr.type_ "submit" 333 | , Attr.class "btn btn-primary" 334 | ] 335 | [ Html.text "Sign in with your email" ] 336 | ] 337 | 338 | 339 | view : Model -> Html Msg 340 | view model = 341 | Html.div [ Attr.class "container" ] 342 | [ Html.div [ Attr.class "row" ] 343 | [ Html.div [ Attr.class "col-sm-7" ] 344 | [ Html.h1 [] [ Html.text "My shows" ] 345 | , listView model 346 | ] 347 | , Html.div [ Attr.class "col-sm-5" ] 348 | [ showForm model, authView model ] 349 | ] 350 | ] 351 | -------------------------------------------------------------------------------- /Model.elm: -------------------------------------------------------------------------------- 1 | module Model 2 | exposing 3 | ( AuthToken 4 | , Flags 5 | , FormMsg(..) 6 | , Model 7 | , Msg(..) 8 | , Genre 9 | , OrderBy(..) 10 | , Show 11 | , dummyShows 12 | , filterGenre 13 | , init 14 | , update 15 | , sortShows 16 | ) 17 | 18 | import Set 19 | import Json.Decode as Decode 20 | import Json.Encode as Encode 21 | import Validate exposing (..) 22 | import Kinto 23 | import Navigation 24 | import Http exposing (encodeUri) 25 | import Ports 26 | 27 | 28 | type alias Flags = 29 | { authToken : Maybe AuthToken 30 | } 31 | 32 | 33 | type alias AuthToken = 34 | String 35 | 36 | 37 | type alias Genre = 38 | String 39 | 40 | 41 | type alias Show = 42 | { title : String 43 | , description : Maybe String 44 | , rating : Maybe Int 45 | , genres : List Genre 46 | } 47 | 48 | 49 | type alias Backup = 50 | { shows : List Show } 51 | 52 | 53 | type alias Model = 54 | { appUrl : String 55 | , authUrl : String 56 | , authToken : Maybe AuthToken 57 | , shows : List Show 58 | , currentOrderBy : OrderBy 59 | , currentGenre : Maybe Genre 60 | , allGenres : Set.Set Genre 61 | , formData : Show 62 | , formErrors : List String 63 | , formEdit : Maybe String 64 | } 65 | 66 | 67 | type OrderBy 68 | = TitleAsc 69 | | RatingAsc 70 | | RatingDesc 71 | 72 | 73 | type FormMsg 74 | = UpdateTitle String 75 | | UpdateDescription String 76 | | UpdateGenres String 77 | | UpdateRating String 78 | 79 | 80 | type Msg 81 | = NoOp 82 | | UrlChange Navigation.Location 83 | | Logout 84 | | BackupSaved (Result Kinto.Error Backup) 85 | | BackupReceived (Result Kinto.Error Backup) 86 | | LoadShows (List Show) 87 | | RateShow String Int 88 | | EditShow Show 89 | | DeleteShow Show 90 | | SetOrderBy OrderBy 91 | | RefineGenre Genre 92 | | ClearGenre 93 | | FormEvent FormMsg 94 | | FormSubmit 95 | 96 | 97 | dummyShows : List Show 98 | dummyShows = 99 | [ { title = "Breaking Bad" 100 | , description = 101 | String.join 102 | " " 103 | [ "Breaking Bad follows protagonist Walter White, a chemistry" 104 | , "teacher who lives in New Mexico and has been diagnosed with" 105 | , "lung cancer. He badly needs to find money for fighting it..." 106 | ] 107 | |> Just 108 | , rating = Nothing 109 | , genres = [ "drama", "crime", "thriller" ] 110 | } 111 | , { title = "Better Call Saul" 112 | , description = 113 | String.join 114 | " " 115 | [ "Better Call Saul is the prequel to the award-winning series" 116 | , "Breaking Bad. The series will track Jimmy's transformation" 117 | , """into Saul Goodman, the man who puts "criminal" in""" 118 | , """"criminal lawyer".""" 119 | ] 120 | |> Just 121 | , rating = Nothing 122 | , genres = [ "drama", "crime" ] 123 | } 124 | ] 125 | 126 | 127 | kintoServerUrl : String 128 | kintoServerUrl = 129 | "https://kinto.dev.mozaws.net/v1/" 130 | 131 | 132 | authHashPattern : String 133 | authHashPattern = 134 | "#auth=" 135 | 136 | 137 | authRedirectUrl : String -> String 138 | authRedirectUrl appUrl = 139 | encodeUri <| appUrl ++ authHashPattern 140 | 141 | 142 | getAuthUrl : String -> String 143 | getAuthUrl serverRoot = 144 | serverRoot ++ "portier/login" 145 | 146 | 147 | init : Flags -> Navigation.Location -> ( Model, Cmd Msg ) 148 | init flags location = 149 | let 150 | appUrl = 151 | extractAppUrl location 152 | 153 | authUrl = 154 | getAuthUrl kintoServerUrl 155 | 156 | authToken = 157 | case flags.authToken of 158 | Just token -> 159 | Just token 160 | 161 | Nothing -> 162 | extractAuthToken location 163 | 164 | commands = 165 | case authToken of 166 | Just token -> 167 | [ Ports.saveAuthToken <| Just token 168 | , fetchBackup <| Just token 169 | , Navigation.newUrl "#" 170 | ] 171 | 172 | Nothing -> 173 | [ fetchBackup authToken ] 174 | in 175 | { appUrl = appUrl 176 | , authUrl = authUrl 177 | , authToken = authToken 178 | , shows = [] 179 | , currentOrderBy = TitleAsc 180 | , currentGenre = Nothing 181 | , allGenres = extractAllGenres [] 182 | , formData = initFormData 183 | , formErrors = [] 184 | , formEdit = Nothing 185 | } 186 | ! commands 187 | 188 | 189 | initFormData : Show 190 | initFormData = 191 | Show "" Nothing Nothing [] 192 | 193 | 194 | ifShowExists : Model -> error -> Validator error String 195 | ifShowExists { shows, formEdit } = 196 | ifInvalid 197 | (\title -> 198 | -- Do not check for uniqueness if a show is being edited 199 | case formEdit of 200 | Nothing -> 201 | List.any (\show -> show.title == title) shows 202 | 203 | Just _ -> 204 | False 205 | ) 206 | 207 | 208 | extractAppUrl : Navigation.Location -> String 209 | extractAppUrl { origin, pathname } = 210 | origin ++ pathname 211 | 212 | 213 | extractAuthToken : Navigation.Location -> Maybe AuthToken 214 | extractAuthToken { hash } = 215 | case (String.split authHashPattern hash) of 216 | [ _, authToken ] -> 217 | Just authToken 218 | 219 | _ -> 220 | Nothing 221 | 222 | 223 | validateShow : Model -> Show -> List String 224 | validateShow model = 225 | Validate.all 226 | [ .title >> ifBlank "Please enter a title." 227 | , .title >> (ifShowExists model) "This show is already listed." 228 | ] 229 | 230 | 231 | updateShow : String -> (Show -> Show) -> List Show -> List Show 232 | updateShow title updateShow shows = 233 | shows 234 | |> List.map 235 | (\show -> 236 | if show.title == title then 237 | updateShow show 238 | else 239 | show 240 | ) 241 | 242 | 243 | deleteShow : Show -> List Show -> List Show 244 | deleteShow { title } shows = 245 | shows |> List.filter (\show -> title /= show.title) 246 | 247 | 248 | rateShow : String -> Int -> List Show -> List Show 249 | rateShow title rating shows = 250 | shows |> updateShow title (\show -> { show | rating = Just rating }) 251 | 252 | 253 | processForm : Model -> Model 254 | processForm ({ formData, formEdit, shows } as model) = 255 | let 256 | -- Ensure entered genres are unique for this show 257 | processedFormData = 258 | { formData | genres = formData.genres |> Set.fromList |> Set.toList } 259 | 260 | updatedShows = 261 | case formEdit of 262 | Nothing -> 263 | processedFormData :: shows 264 | 265 | Just edited -> 266 | updateShow edited (always processedFormData) shows 267 | in 268 | -- Notes: 269 | -- We clear current genre filter to ensure added show is listed 270 | { model 271 | | shows = updatedShows 272 | , currentGenre = Nothing 273 | , allGenres = extractAllGenres updatedShows 274 | , formData = initFormData 275 | , formErrors = [] 276 | , formEdit = Nothing 277 | } 278 | 279 | 280 | sortShows : OrderBy -> List Show -> List Show 281 | sortShows order shows = 282 | case order of 283 | TitleAsc -> 284 | List.sortBy (.title >> String.toLower) shows 285 | 286 | RatingAsc -> 287 | List.sortBy (\show -> Maybe.withDefault 0 show.rating) shows 288 | 289 | RatingDesc -> 290 | List.reverse <| sortShows RatingAsc shows 291 | 292 | 293 | filterGenre : Maybe Genre -> List Show -> List Show 294 | filterGenre genre shows = 295 | case genre of 296 | Nothing -> 297 | shows 298 | 299 | Just currentGenre -> 300 | shows |> List.filter (\show -> List.member currentGenre show.genres) 301 | 302 | 303 | extractAllGenres : List Show -> Set.Set Genre 304 | extractAllGenres shows = 305 | List.map .genres shows 306 | |> List.concat 307 | |> Set.fromList 308 | 309 | 310 | stringToGenres : String -> List Genre 311 | stringToGenres genresString = 312 | String.split "," genresString |> List.map (String.trim << String.toLower) 313 | 314 | 315 | updateForm : FormMsg -> Show -> Show 316 | updateForm formMsg formData = 317 | case formMsg of 318 | UpdateTitle title -> 319 | { formData | title = title } 320 | 321 | UpdateDescription description -> 322 | { formData 323 | | description = 324 | if description == "" then 325 | Nothing 326 | else 327 | Just description 328 | } 329 | 330 | UpdateGenres genresString -> 331 | { formData | genres = stringToGenres genresString } 332 | 333 | UpdateRating rating -> 334 | { formData | rating = String.toInt rating |> Result.toMaybe } 335 | 336 | 337 | update : Msg -> Model -> ( Model, Cmd Msg ) 338 | update msg ({ authToken, shows, formData } as model) = 339 | case msg of 340 | NoOp -> 341 | model ! [] 342 | 343 | UrlChange location -> 344 | model ! [] 345 | 346 | Logout -> 347 | { model 348 | | authToken = Nothing 349 | , shows = [] 350 | , currentOrderBy = TitleAsc 351 | , currentGenre = Nothing 352 | , allGenres = extractAllGenres [] 353 | , formData = initFormData 354 | , formErrors = [] 355 | , formEdit = Nothing 356 | , shows = [] 357 | } 358 | ! [ Ports.saveAuthToken Nothing ] 359 | 360 | LoadShows shows -> 361 | { model | shows = shows, allGenres = extractAllGenres shows } ! [] 362 | 363 | BackupReceived (Ok backup) -> 364 | { model | shows = backup.shows, allGenres = extractAllGenres backup.shows } ! [] 365 | 366 | BackupReceived (Err error) -> 367 | -- TODO handle error 368 | model ! [] 369 | 370 | BackupSaved (Ok backup) -> 371 | model ! [] 372 | 373 | BackupSaved (Err error) -> 374 | model ! [] 375 | 376 | EditShow show -> 377 | { model | formData = show, formEdit = Just show.title } ! [] 378 | 379 | DeleteShow show -> 380 | let 381 | updatedShows = 382 | deleteShow show shows 383 | in 384 | { model 385 | | shows = updatedShows 386 | , allGenres = extractAllGenres updatedShows 387 | } 388 | ! [ saveBackup authToken updatedShows ] 389 | 390 | RateShow title rating -> 391 | let 392 | updatedModel = 393 | { model | shows = rateShow title rating shows } 394 | in 395 | updatedModel ! [ saveBackup authToken updatedModel.shows ] 396 | 397 | SetOrderBy orderBy -> 398 | { model | currentOrderBy = orderBy } ! [] 399 | 400 | RefineGenre genre -> 401 | { model | currentGenre = Just genre } ! [] 402 | 403 | ClearGenre -> 404 | { model | currentGenre = Nothing } ! [] 405 | 406 | FormSubmit -> 407 | let 408 | errors = 409 | formData |> validateShow model 410 | in 411 | if List.length errors > 0 then 412 | { model | formErrors = errors } ! [] 413 | else 414 | let 415 | updatedModel = 416 | processForm model 417 | in 418 | updatedModel ! [ saveBackup authToken updatedModel.shows ] 419 | 420 | FormEvent formMsg -> 421 | { model | formData = updateForm formMsg formData } ! [] 422 | 423 | 424 | maybeEncode : (a -> Encode.Value) -> Maybe a -> Encode.Value 425 | maybeEncode encode thing = 426 | case thing of 427 | Nothing -> 428 | Encode.null 429 | 430 | Just value -> 431 | encode value 432 | 433 | 434 | encodeShow : Show -> Encode.Value 435 | encodeShow show = 436 | Encode.object 437 | [ ( "title", Encode.string show.title ) 438 | , ( "description", maybeEncode Encode.string show.description ) 439 | , ( "genres", Encode.list (List.map Encode.string show.genres) ) 440 | , ( "rating", maybeEncode Encode.int show.rating ) 441 | ] 442 | 443 | 444 | encodeShows : List Show -> Encode.Value 445 | encodeShows shows = 446 | Encode.list <| List.map encodeShow shows 447 | 448 | 449 | encodeBackup : List Show -> Encode.Value 450 | encodeBackup shows = 451 | Encode.object [ ( "shows", encodeShows shows ) ] 452 | 453 | 454 | decodeShow : Decode.Decoder Show 455 | decodeShow = 456 | Decode.map4 Show 457 | (Decode.field "title" Decode.string) 458 | (Decode.maybe <| Decode.field "description" Decode.string) 459 | (Decode.maybe <| Decode.field "rating" Decode.int) 460 | (Decode.field "genres" <| Decode.list Decode.string) 461 | 462 | 463 | decodeShows : Decode.Decoder (List Show) 464 | decodeShows = 465 | Decode.list decodeShow 466 | 467 | 468 | decodeBackup : Decode.Decoder Backup 469 | decodeBackup = 470 | Decode.map Backup <| 471 | Decode.field "shows" decodeShows 472 | 473 | 474 | client : String -> Kinto.Client 475 | client token = 476 | Kinto.client kintoServerUrl <| Kinto.Custom "Portier" token 477 | 478 | 479 | backupResource : Kinto.Resource Backup 480 | backupResource = 481 | Kinto.collectionResource "default" decodeBackup 482 | 483 | 484 | saveBackup : Maybe AuthToken -> List Show -> Cmd Msg 485 | saveBackup authToken shows = 486 | case authToken of 487 | Just token -> 488 | client token 489 | |> Kinto.replace backupResource "myshows" (encodeBackup shows) 490 | |> Kinto.send BackupSaved 491 | 492 | Nothing -> 493 | Cmd.none 494 | 495 | 496 | fetchBackup : Maybe AuthToken -> Cmd Msg 497 | fetchBackup authToken = 498 | case authToken of 499 | Just token -> 500 | client token 501 | |> Kinto.get backupResource "myshows" 502 | |> Kinto.send BackupReceived 503 | 504 | Nothing -> 505 | Cmd.none 506 | --------------------------------------------------------------------------------