all repos

onasty @ 0401811

a one-time notes service

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

Olexandr Smirnov Olexandr Smirnov
ss2316544@gmail.com
web: button component; improve code consistency (#168)..., 10 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
    , dontBurnBeforeExpiration : 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
      , dontBurnBeforeExpiration = True
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
                , burnBeforeExpiration = not model.dontBurnBeforeExpiration
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 burnBeforeExpiration ->
169
            ( { model | dontBurnBeforeExpiration = burnBeforeExpiration }, 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
-- TODO: validate the form
245
246
247
viewCreateNoteForm : Model -> (String -> String) -> Html Msg
248
viewCreateNoteForm model appUrl =
249
    H.form
250
        [ E.onSubmit UserClickedSubmit
251
        , A.class "space-y-6"
252
        ]
253
        [ viewTextarea
254
        , Components.Form.input
255
            { id = "slug"
256
            , field = Slug
257
            , label = "Custom URL Slug (optional)"
258
            , placeholder = "my-unique-slug"
259
            , type_ = "text"
260
            , helpText = Just "Leave empty to generate a random slug"
261
            , prefix = Just (appUrl "")
262
            , onInput = UserUpdatedInput Slug
263
            , required = False
264
            , value = Maybe.withDefault "" model.slug
265
            }
266
        , H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ]
267
            [ H.div [ A.class "space-y-6" ]
268
                [ Components.Form.input
269
                    { id = "password"
270
                    , field = Password
271
                    , label = "Password Protection (optional)"
272
                    , type_ = "password"
273
                    , placeholder = "Enter password to protect this paste"
274
                    , helpText = Just "Viewers will need this password to access the paste"
275
                    , prefix = Nothing
276
                    , onInput = UserUpdatedInput Password
277
                    , required = False
278
                    , value = Maybe.withDefault "" model.password
279
                    }
280
                ]
281
            , H.div [ A.class "space-y-6" ]
282
                [ viewExpirationTimeSelector
283
                , viewBurnBeforeExpirationCheckbox
284
                ]
285
            ]
286
        , H.div [ A.class "flex justify-end" ]
287
            [ Components.Form.submitButton
288
                { text = "Create note"
289
                , style = Components.Form.Primary (isFormDisabled model)
290
                , disabled = False
291
                , class = ""
292
                }
293
            ]
294
        ]
295
296
297
viewTextarea : Html Msg
298
viewTextarea =
299
    H.div [ A.class "space-y-2" ]
300
        [ H.label
301
            [ A.for (fromFieldToName Content)
302
            , A.class "block text-sm font-medium text-gray-700 mb-2"
303
            ]
304
            [ H.text "Content" ]
305
        , H.textarea
306
            [ E.onInput (UserUpdatedInput Content)
307
            , A.id (fromFieldToName Content)
308
            , A.placeholder "Write your note here..."
309
            , A.required True
310
            , A.rows 20
311
            , 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"
312
            ]
313
            []
314
        ]
315
316
317
viewExpirationTimeSelector : Html Msg
318
viewExpirationTimeSelector =
319
    H.div []
320
        [ H.label [ A.for (fromFieldToName ExpirationTime), A.class "block text-sm font-medium text-gray-700 mb-2" ] [ H.text "Expiration Time (optional)" ]
321
        , H.select
322
            [ A.id (fromFieldToName ExpirationTime)
323
            , 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"
324
            , E.onInput (UserUpdatedInput ExpirationTime)
325
            ]
326
            (List.map
327
                (\e ->
328
                    H.option
329
                        [ A.value (String.fromInt e.value) ]
330
                        [ H.text e.text ]
331
                )
332
                expirationOptions
333
            )
334
        ]
335
336
337
viewBurnBeforeExpirationCheckbox : Html Msg
338
viewBurnBeforeExpirationCheckbox =
339
    H.div [ A.class "space-y-2" ]
340
        [ H.div [ A.class "flex items-start space-x-3" ]
341
            [ H.input
342
                [ E.onCheck UserClickedCheckbox
343
                , A.id "burn"
344
                , A.type_ "checkbox"
345
                , A.class "mt-1 h-4 w-4 text-black border-gray-300 rounded focus:ring-black focus:ring-2"
346
                ]
347
                []
348
            , H.div [ A.class "flex-1" ]
349
                [ H.label [ A.for "burn", A.class "block text-sm font-medium text-gray-700 cursor-pointer" ]
350
                    [ H.text "Don't delete note until expiration time, even if it has been read it" ]
351
                ]
352
            ]
353
        ]
354
355
356
isFormDisabled : Model -> Bool
357
isFormDisabled model =
358
    String.isEmpty model.content
359
360
361
fromFieldToName : Field -> String
362
fromFieldToName field =
363
    case field of
364
        Content ->
365
            "content"
366
367
        Slug ->
368
            "slug"
369
370
        Password ->
371
            "password"
372
373
        ExpirationTime ->
374
            "expiration"
375
376
377
378
-- VIEW NOTE CREATED
379
380
381
viewNoteCreated : Bool -> (String -> String) -> String -> Html Msg
382
viewNoteCreated userClickedCopyLink appUrl slug =
383
    H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-6" ]
384
        [ H.div [ A.class "border border-green-300 rounded-md p-4 mb-4" ]
385
            [ H.p [ A.class "text-sm text-gray-600 mb-2" ] [ H.text "Your paste is available at:" ]
386
            , H.p [ A.class "font-mono text-sm text-gray-800" ] [ H.text (appUrl slug) ]
387
            ]
388
        , H.div [ A.class "flex gap-3" ]
389
            [ Components.Form.button
390
                { text = "Create New Paste"
391
                , onClick = UserClickedCreateNewNote
392
                , style = Components.Form.Primary False
393
                , disabled = False
394
                }
395
            , Components.Form.button
396
                { style = Components.Form.Secondary userClickedCopyLink
397
                , onClick = UserClickedCopyLink
398
                , disabled = userClickedCopyLink
399
                , text =
400
                    if userClickedCopyLink then
401
                        "Copied!"
402
403
                    else
404
                        "Copy URL"
405
                }
406
            ]
407
        ]