all repos

onasty @ f6a62bab220c1b14268dcf3df3194f8948407279

a one-time notes service

onasty/web/src/Pages/Secret/Slug_.elm (view raw)

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