all repos

onasty @ ef386ed36d8e402f1424cd3663fa9ad8b25c3eb9

a one-time notes service

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

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