all repos

onasty @ 450a6a9

a one-time notes service

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

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