all repos

onasty @ 5290b5fe6a6bba7e2328582aae3c4a359ec60e01

a one-time notes service

onasty/web/src/Pages/Secret/Slug_.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.Secret.Slug_ exposing (Model, Msg, PageVariant, page)
2
3
import Api
4
import Api.Note
5
import Components.Error
6
import Components.Note
7
import Data.Note exposing (Metadata, Note)
8
import Effect exposing (Effect)
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 Route exposing (Route)
15
import Shared
16
import View exposing (View)
17
18
19
page : Shared.Model -> Route { slug : String } -> Page Model Msg
20
page _ route =
21
    Page.new
22
        { init = init route.params.slug
23
        , update = update
24
        , subscriptions = subscriptions
25
        , view = view
26
        }
27
        |> Page.withLayout (\_ -> Layouts.Header {})
28
29
30
31
-- INIT
32
33
34
type PageVariant
35
    = RequestNote
36
    | ShowNote (Api.Response Note)
37
    | NotFound
38
39
40
type alias Model =
41
    { page : PageVariant
42
    , metadata : Api.Response Metadata
43
    , slug : String
44
    , password : Maybe String
45
    }
46
47
48
init : String -> () -> ( Model, Effect Msg )
49
init slug () =
50
    ( { page = RequestNote
51
      , metadata = Api.Loading
52
      , slug = slug
53
      , password = Nothing
54
      }
55
    , Api.Note.getMetadata
56
        { onResponse = ApiGetMetadataResponded
57
        , slug = slug
58
        }
59
    )
60
61
62
63
-- UPDATE
64
65
66
type Msg
67
    = UserClickedViewNote
68
    | UserClickedCopyContent
69
    | UserUpdatedPassword String
70
    | ApiGetNoteResponded (Result Api.Error Note)
71
    | ApiGetMetadataResponded (Result Api.Error Metadata)
72
73
74
update : Msg -> Model -> ( Model, Effect Msg )
75
update msg model =
76
    case msg of
77
        UserClickedViewNote ->
78
            ( { model | page = ShowNote Api.Loading }
79
            , Api.Note.get
80
                { onResponse = ApiGetNoteResponded
81
                , password = model.password
82
                , slug = model.slug
83
                }
84
            )
85
86
        UserClickedCopyContent ->
87
            case model.page of
88
                ShowNote (Api.Success note) ->
89
                    ( model, Effect.sendToClipboard note.content )
90
91
                _ ->
92
                    ( model, Effect.none )
93
94
        UserUpdatedPassword password ->
95
            ( { model | password = Just password }, Effect.none )
96
97
        ApiGetNoteResponded (Ok note) ->
98
            ( { model | page = ShowNote (Api.Success note) }, Effect.none )
99
100
        ApiGetNoteResponded (Err error) ->
101
            ( { model | page = ShowNote (Api.Failure error) }, Effect.none )
102
103
        ApiGetMetadataResponded (Ok metadata) ->
104
            ( { model | metadata = Api.Success metadata }, Effect.none )
105
106
        ApiGetMetadataResponded (Err error) ->
107
            ( { model | page = NotFound, metadata = Api.Failure error }, Effect.none )
108
109
110
111
-- SUBSCRIPTIONS
112
113
114
subscriptions : Model -> Sub Msg
115
subscriptions _ =
116
    Sub.none
117
118
119
120
-- VIEW
121
122
123
view : Model -> View Msg
124
view model =
125
    { title = "View note"
126
    , body =
127
        [ H.div
128
            [ A.class "w-full max-w-4xl mx-auto" ]
129
            [ H.div
130
                [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ]
131
                (case model.metadata of
132
                    Api.Success metadata ->
133
                        viewPage model.slug model.page metadata model.password
134
135
                    Api.Loading ->
136
                        [ viewHeader { title = "View note", subtitle = "Loading note metadata..." }
137
                        , viewOpenNote { slug = model.slug, hasPassword = False, password = Nothing, isLoading = True }
138
                        ]
139
140
                    Api.Failure error ->
141
                        [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" }
142
                        , if Api.is404 error then
143
                            viewNoteNotFound model.slug
144
145
                          else
146
                            Components.Error.error (Api.errorMessage error)
147
                        ]
148
                )
149
            ]
150
        ]
151
    }
152
153
154
viewPage : String -> PageVariant -> Metadata -> Maybe String -> List (Html Msg)
155
viewPage slug variant metadata password =
156
    case variant of
157
        RequestNote ->
158
            [ viewHeader { title = "View note", subtitle = "Click the button below to view the note content" }
159
            , viewOpenNote { slug = slug, hasPassword = metadata.hasPassword, password = password, isLoading = False }
160
            ]
161
162
        ShowNote apiResp ->
163
            case apiResp of
164
                Api.Success note ->
165
                    [ viewShowNoteHeader slug note
166
                    , viewNoteContent note
167
                    ]
168
169
                Api.Loading ->
170
                    [ viewHeader { title = "View note", subtitle = "Click the button below to view the note content" }
171
                    , viewOpenNote { slug = slug, hasPassword = metadata.hasPassword, password = password, isLoading = True }
172
                    ]
173
174
                Api.Failure _ ->
175
                    [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" }
176
                    , viewNoteNotFound slug
177
                    ]
178
179
        NotFound ->
180
            [ viewNoteNotFound slug ]
181
182
183
184
-- HEADER
185
186
187
viewHeader : { title : String, subtitle : String } -> Html msg
188
viewHeader options =
189
    H.div [ A.class "p-6 pb-4 border-b border-gray-200" ]
190
        [ H.h1
191
            [ A.class "text-2xl font-bold text-gray-900" ]
192
            [ H.text options.title ]
193
        , H.p [ A.class "text-gray-600 mt-2" ] [ H.text options.subtitle ]
194
        ]
195
196
197
viewShowNoteHeader : String -> Note -> Html Msg
198
viewShowNoteHeader slug note =
199
    H.div []
200
        [ if note.burnBeforeExpiration then
201
            H.div [ A.class "bg-orange-50 border-b border-orange-200 p-4" ]
202
                [ H.div [ A.class "flex items-center gap-3" ]
203
                    [ H.div [ A.class "w-6 h-6 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0" ]
204
                        [ Components.Note.warningSvg ]
205
                    , H.p [ A.class "text-orange-800 text-sm font-medium" ]
206
                        [ H.text "This note was destroyed. If you need to keep it, copy it before closing this window." ]
207
                    ]
208
                ]
209
210
          else
211
            H.text ""
212
        , H.div [ A.class "p-6 pb-4 border-b border-gray-200" ]
213
            [ H.div [ A.class "flex justify-between items-start" ]
214
                [ H.div []
215
                    [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text ("Note: " ++ slug) ]
216
                    , H.div [ A.class "text-sm text-gray-500 mt-2 space-y-1" ]
217
                        [ H.p [] [ H.text ("Created: " ++ note.createdAt) ]
218
                        , case note.expiresAt of
219
                            Just expiresAt ->
220
                                -- TODO: format time properly
221
                                H.p [] [ H.text ("Expires at: " ++ expiresAt) ]
222
223
                            Nothing ->
224
                                H.text ""
225
                        ]
226
                    ]
227
                , H.div [ A.class "flex gap-2" ]
228
                    [ H.button
229
                        [ E.onClick UserClickedCopyContent
230
                        , A.class "px-3 py-2 text-sm border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors"
231
                        ]
232
                        [ H.text "Copy Content" ]
233
                    ]
234
                ]
235
            ]
236
        ]
237
238
239
240
-- NOTE
241
242
243
viewNoteNotFound : String -> Html msg
244
viewNoteNotFound slug =
245
    H.div [ A.class "p-6" ]
246
        [ H.div [ A.class "text-center py-12" ]
247
            [ H.div [ A.class "w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4" ]
248
                [ Components.Note.noteNotFoundSvg ]
249
            , H.h2 [ A.class "text-xl font-semibold text-gray-900 mb-2" ]
250
                [ H.text ("Note " ++ slug ++ " Not Found") ]
251
            , H.div [ A.class "text-gray-600 mb-6 space-y-2" ]
252
                [ H.p []
253
                    [ H.span [ A.class "font-bold" ] [ H.text "This note may have:" ]
254
                    , H.ul [ A.class "text-sm space-y-1 list-disc list-inside text-left max-w-md mx-auto" ]
255
                        [ H.li [] [ H.text "Expired and been deleted" ]
256
                        , H.li [] [ H.text "Have different password" ]
257
                        , H.li [] [ H.text "Been deleted by the creator" ]
258
                        , H.li [] [ H.text "Been burned after reading" ]
259
                        , H.li [] [ H.text "Never existed or the URL is incorrect" ]
260
                        ]
261
                    ]
262
                ]
263
            ]
264
        ]
265
266
267
viewOpenNote :
268
    { slug : String
269
    , hasPassword : Bool
270
    , isLoading : Bool
271
    , password : Maybe String
272
    }
273
    -> Html Msg
274
viewOpenNote opts =
275
    let
276
        isDisabled =
277
            opts.hasPassword && Maybe.withDefault "" opts.password == ""
278
279
        buttonData =
280
            let
281
                base =
282
                    "px-6 py-3 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors"
283
            in
284
            if opts.isLoading then
285
                { text = "Loading Note...", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" }
286
287
            else if isDisabled then
288
                { text = "View Note", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" }
289
290
            else
291
                { text = "View Note", class = base ++ " bg-black text-white hover:bg-gray-800" }
292
    in
293
    H.div [ A.class "p-6" ]
294
        [ H.div [ A.class "text-center py-12" ]
295
            [ H.div [ A.class "mb-6" ]
296
                [ H.div [ A.class "w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4" ]
297
                    [ Components.Note.noteIconSvg ]
298
                , H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-2" ] [ H.text opts.slug ]
299
                , H.p [ A.class "text-gray-600 mb-6" ] [ H.text "You're about read and destroy the note." ]
300
                ]
301
            , H.form
302
                [ E.onSubmit UserClickedViewNote
303
                , A.class "max-w-sm mx-auto space-y-4"
304
                ]
305
                [ if opts.hasPassword then
306
                    H.div
307
                        [ A.class "space-y-2" ]
308
                        [ H.label
309
                            [ A.class "block text-sm font-medium text-gray-700 text-left" ]
310
                            [ H.text "Password" ]
311
                        , H.input
312
                            [ E.onInput UserUpdatedPassword
313
                            , 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"
314
                            ]
315
                            []
316
                        ]
317
318
                  else
319
                    H.text ""
320
                , H.button
321
                    [ A.class buttonData.class
322
                    , A.type_ "submit"
323
                    , A.disabled isDisabled
324
                    ]
325
                    [ H.text buttonData.text ]
326
                ]
327
            ]
328
        ]
329
330
331
viewNoteContent : Note -> Html msg
332
viewNoteContent note =
333
    H.div [ A.class "p-6" ]
334
        [ H.div [ A.class "bg-gray-50 border border-gray-200 rounded-md p-4" ]
335
            [ H.pre
336
                [ A.class "whitespace-pre-wrap font-mono text-sm text-gray-800 overflow-x-auto" ]
337
                [ H.text note.content ]
338
            ]
339
        ]