all repos

onasty @ 6e28490

a one-time notes service

onasty/web/src/Pages/Home_.elm (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat(web): add dashboard (#214)..., 8 months ago
1
module Pages.Home_ exposing (Model, Msg, PageVariant, page)
2
3
import Api
4
import Api.Note
5
import Components.Box
6
import Components.Form
7
import Components.Utils
8
import Constants exposing (expirationOptions)
9
import Data.Note as Note
10
import Effect exposing (Effect)
11
import Html as H exposing (Html)
12
import Html.Attributes as A
13
import Html.Events as E
14
import Layouts
15
import Page exposing (Page)
16
import Process
17
import Route exposing (Route)
18
import Shared
19
import Task
20
import Time exposing (Posix)
21
import View exposing (View)
22
23
24
page : Shared.Model -> Route () -> Page Model Msg
25
page shared _ =
26
    Page.new
27
        { init = init shared
28
        , update = update shared
29
        , subscriptions = subscriptions
30
        , view = view shared
31
        }
32
        |> Page.withLayout (\_ -> Layouts.Header {})
33
34
35
36
-- INIT
37
38
39
type alias Model =
40
    { pageVariant : PageVariant
41
    , content : String
42
    , slug : Maybe String
43
    , password : Maybe String
44
    , expirationTime : Maybe Int
45
    , keepBeforeExpiration : Bool
46
    , apiError : Maybe Api.Error
47
    , userClickedCopyLink : Bool
48
    , now : Maybe Posix
49
    }
50
51
52
type PageVariant
53
    = CreateNote
54
    | NoteCreated String
55
56
57
init : Shared.Model -> () -> ( Model, Effect Msg )
58
init _ () =
59
    ( { pageVariant = CreateNote
60
      , content = ""
61
      , slug = Nothing
62
      , password = Nothing
63
      , expirationTime = Nothing
64
      , keepBeforeExpiration = False
65
      , userClickedCopyLink = False
66
      , apiError = Nothing
67
      , now = Nothing
68
      }
69
    , Effect.none
70
    )
71
72
73
74
-- UPDATE
75
76
77
type Msg
78
    = Tick Posix
79
    | CopyButtonReset
80
    | UserUpdatedInput Field String
81
    | UserClickedCheckbox Bool
82
    | UserClickedSubmit
83
    | UserClickedCreateNewNote
84
    | UserClickedCopyLink
85
    | ApiCreateNoteResponded (Result Api.Error Note.CreateResponse)
86
87
88
type Field
89
    = Content
90
    | Slug
91
    | Password
92
    | ExpirationTime
93
94
95
update : Shared.Model -> Msg -> Model -> ( Model, Effect Msg )
96
update shared msg model =
97
    case msg of
98
        Tick now ->
99
            ( { model | now = Just now }, Effect.none )
100
101
        CopyButtonReset ->
102
            ( { model | userClickedCopyLink = False }, Effect.none )
103
104
        UserClickedSubmit ->
105
            let
106
                expiresAt =
107
                    case ( model.now, model.expirationTime ) of
108
                        ( Just now, Just expirationTime ) ->
109
                            Time.millisToPosix (Time.posixToMillis now + expirationTime)
110
111
                        _ ->
112
                            Time.millisToPosix 0
113
            in
114
            ( model
115
            , Api.Note.create
116
                { onResponse = ApiCreateNoteResponded
117
                , content = model.content
118
                , slug = model.slug
119
                , password = model.password
120
                , keepBeforeExpiration = model.keepBeforeExpiration
121
                , expiresAt = expiresAt
122
                }
123
            )
124
125
        UserClickedCreateNewNote ->
126
            ( { model
127
                | pageVariant = CreateNote
128
                , content = ""
129
                , slug = Nothing
130
                , password = Nothing
131
                , apiError = Nothing
132
              }
133
            , Effect.none
134
            )
135
136
        UserClickedCopyLink ->
137
            ( { model | userClickedCopyLink = True }
138
            , Effect.batch
139
                [ Effect.sendCmd (Task.perform (\_ -> CopyButtonReset) (Process.sleep 2000))
140
                , Effect.sendToClipboard (secretUrl shared.appURL (Maybe.withDefault "" model.slug))
141
                ]
142
            )
143
144
        UserUpdatedInput Content content ->
145
            ( { model | content = content }, Effect.none )
146
147
        UserUpdatedInput Slug slug ->
148
            if String.isEmpty slug then
149
                ( { model | slug = Nothing }, Effect.none )
150
151
            else
152
                ( { model | slug = Just slug }, Effect.none )
153
154
        UserUpdatedInput Password password ->
155
            if String.isEmpty password then
156
                ( { model | password = Nothing }, Effect.none )
157
158
            else
159
                ( { model | password = Just password }, Effect.none )
160
161
        UserUpdatedInput ExpirationTime expirationTime ->
162
            if expirationTime == "0" then
163
                ( { model | expirationTime = Nothing }, Effect.none )
164
165
            else
166
                ( { model | expirationTime = String.toInt expirationTime }, Effect.none )
167
168
        UserClickedCheckbox keepBeforeExpiration ->
169
            ( { model | keepBeforeExpiration = keepBeforeExpiration }, Effect.none )
170
171
        ApiCreateNoteResponded (Ok response) ->
172
            ( { model | pageVariant = NoteCreated response.slug, slug = Just response.slug, apiError = Nothing }, Effect.none )
173
174
        ApiCreateNoteResponded (Err error) ->
175
            ( { model | apiError = Just error }, Effect.none )
176
177
178
subscriptions : Model -> Sub Msg
179
subscriptions model =
180
    case model.expirationTime of
181
        Just _ ->
182
            Time.every 1000 Tick
183
184
        _ ->
185
            Sub.none
186
187
188
189
-- VIEW
190
191
192
secretUrl : String -> String -> String
193
secretUrl appUrl slug =
194
    appUrl ++ "/secret/" ++ slug
195
196
197
view : Shared.Model -> Model -> View Msg
198
view shared model =
199
    let
200
        appUrl =
201
            secretUrl shared.appURL
202
    in
203
    { title = "Onasty"
204
    , body =
205
        [ Components.Utils.commonContainer
206
            [ viewHeader model.pageVariant model.apiError
207
            , H.div [ A.class "p-6 space-y-6" ]
208
                [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e))
209
                , case model.pageVariant of
210
                    CreateNote ->
211
                        viewCreateNoteForm model appUrl
212
213
                    NoteCreated slug ->
214
                        Components.Utils.viewIf (model.apiError == Nothing)
215
                            (viewNoteCreated model.userClickedCopyLink appUrl slug)
216
                ]
217
            ]
218
        ]
219
    }
220
221
222
viewHeader : PageVariant -> Maybe Api.Error -> Html Msg
223
viewHeader pageVariant apiError =
224
    H.div [ A.class "p-6 pb-4 border-b border-gray-200" ]
225
        [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ]
226
            [ H.text
227
                (case pageVariant of
228
                    CreateNote ->
229
                        "Create a new note"
230
231
                    NoteCreated _ ->
232
                        if apiError == Nothing then
233
                            "Paste Created Successfully!"
234
235
                        else
236
                            "Could not create the note."
237
                )
238
            ]
239
        ]
240
241
242
243
-- VIEW CREATE NOTE
244
245
246
viewCreateNoteForm : Model -> (String -> String) -> Html Msg
247
viewCreateNoteForm model appUrl =
248
    H.form
249
        [ E.onSubmit UserClickedSubmit
250
        , A.class "space-y-6"
251
        ]
252
        [ viewTextarea
253
        , Components.Form.input
254
            { style =
255
                Components.Form.Complex
256
                    { prefix = appUrl ""
257
                    , helpText = "Leave empty to generate a random slug"
258
                    }
259
            , error = validateSlugInput model.slug
260
            , field = Slug
261
            , id = "slug"
262
            , label = "Custom URL Slug (optional)"
263
            , onInput = UserUpdatedInput Slug
264
            , placeholder = "my-unique-slug"
265
            , required = False
266
            , type_ = "text"
267
            , value = Maybe.withDefault "" model.slug
268
            }
269
        , H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ]
270
            [ H.div [ A.class "space-y-6" ]
271
                [ Components.Form.input
272
                    { style =
273
                        Components.Form.Complex
274
                            { prefix = ""
275
                            , helpText = "Viewers will need this password to access the paste"
276
                            }
277
                    , field = Password
278
                    , id = "password"
279
                    , error = Nothing
280
                    , label = "Password Protection (optional)"
281
                    , onInput = UserUpdatedInput Password
282
                    , placeholder = "Enter password to protect this paste"
283
                    , required = False
284
                    , type_ = "password"
285
                    , value = Maybe.withDefault "" model.password
286
                    }
287
                ]
288
            , H.div [ A.class "space-y-6" ]
289
                [ viewExpirationTimeSelector
290
                , viewKeepBeforeExpirationCheckbox (isCheckBoxDisabled model.expirationTime)
291
                ]
292
            ]
293
        , H.div [ A.class "flex justify-end" ]
294
            [ Components.Form.submitButton
295
                { text = "Create note"
296
                , style = Components.Form.Primary (isFormDisabled model)
297
                , disabled = isFormDisabled model
298
                , class = ""
299
                }
300
            ]
301
        ]
302
303
304
viewTextarea : Html Msg
305
viewTextarea =
306
    H.div [ A.class "space-y-2" ]
307
        [ H.label
308
            [ A.for (fromFieldToName Content)
309
            , A.class "block text-sm font-medium text-gray-700 mb-2"
310
            ]
311
            [ H.text "Content" ]
312
        , H.textarea
313
            [ E.onInput (UserUpdatedInput Content)
314
            , A.id (fromFieldToName Content)
315
            , A.placeholder "Write your note here..."
316
            , A.required True
317
            , A.rows 20
318
            , A.class "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent resize-vertical font-mono text-sm"
319
            ]
320
            []
321
        ]
322
323
324
viewExpirationTimeSelector : Html Msg
325
viewExpirationTimeSelector =
326
    H.div []
327
        [ H.label [ A.for (fromFieldToName ExpirationTime), A.class "block text-sm font-medium text-gray-700 mb-2" ] [ H.text "Expiration Time (optional)" ]
328
        , H.select
329
            [ A.id (fromFieldToName ExpirationTime)
330
            , A.class "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
331
            , E.onInput (UserUpdatedInput ExpirationTime)
332
            ]
333
            (List.map
334
                (\e ->
335
                    H.option
336
                        [ A.value (String.fromInt e.value) ]
337
                        [ H.text e.text ]
338
                )
339
                expirationOptions
340
            )
341
        ]
342
343
344
viewKeepBeforeExpirationCheckbox : Bool -> Html Msg
345
viewKeepBeforeExpirationCheckbox isDisabled =
346
    H.div [ A.class "space-y-2" ]
347
        [ H.div [ A.class "flex items-start space-x-3" ]
348
            [ H.input
349
                [ E.onCheck UserClickedCheckbox
350
                , A.id "kept"
351
                , A.type_ "checkbox"
352
                , A.class "mt-1 h-4 w-4 text-black border-gray-300 rounded focus:ring-black focus:ring-2"
353
                , A.disabled isDisabled
354
                ]
355
                []
356
            , H.div [ A.class "flex-1" ]
357
                [ H.label [ A.for "kept", A.class "block text-sm font-medium text-gray-700 cursor-pointer" ]
358
                    [ H.text "Keep the note until its expiration time, even if it has already been read." ]
359
                , H.span [ A.class "block text-sm font-medium text-gray-500 cursor-pointer" ]
360
                    [ H.text "Can only be used if expiration time is set" ]
361
                ]
362
            ]
363
        ]
364
365
366
isCheckBoxDisabled : Maybe Int -> Bool
367
isCheckBoxDisabled expirationTime =
368
    expirationTime == Nothing
369
370
371
isFormDisabled : Model -> Bool
372
isFormDisabled model =
373
    String.isEmpty model.content
374
        || (validateSlugInput model.slug /= Nothing)
375
376
377
validateSlugInput : Maybe String -> Maybe String
378
validateSlugInput slug =
379
    let
380
        value =
381
            Maybe.withDefault "" slug
382
    in
383
    if not (String.isEmpty value) && String.contains " " value then
384
        Just "Slug cannot contain spaces."
385
386
    else
387
        Nothing
388
389
390
fromFieldToName : Field -> String
391
fromFieldToName field =
392
    case field of
393
        Content ->
394
            "content"
395
396
        Slug ->
397
            "slug"
398
399
        Password ->
400
            "password"
401
402
        ExpirationTime ->
403
            "expiration"
404
405
406
407
-- VIEW NOTE CREATED
408
409
410
viewNoteCreated : Bool -> (String -> String) -> String -> Html Msg
411
viewNoteCreated userClickedCopyLink appUrl slug =
412
    H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-6" ]
413
        [ H.div [ A.class "border border-green-300 rounded-md p-4 mb-4" ]
414
            [ H.p [ A.class "text-sm text-gray-600 mb-2" ] [ H.text "Your paste is available at:" ]
415
            , H.p [ A.class "font-mono text-sm text-gray-800" ] [ H.text (appUrl slug) ]
416
            ]
417
        , H.div [ A.class "flex gap-3" ]
418
            [ Components.Form.button
419
                { text = "Create New Paste"
420
                , onClick = UserClickedCreateNewNote
421
                , style = Components.Form.Primary False
422
                , disabled = False
423
                }
424
            , Components.Form.button
425
                { style = Components.Form.Secondary userClickedCopyLink
426
                , onClick = UserClickedCopyLink
427
                , disabled = userClickedCopyLink
428
                , text =
429
                    if userClickedCopyLink then
430
                        "Copied!"
431
432
                    else
433
                        "Copy URL"
434
                }
435
            ]
436
        ]