all repos

onasty @ d631546

a one-time notes service

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

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