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