all repos

onasty @ f01c95c

a one-time notes service

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

Olexandr Smirnov Olexandr Smirnov
ss2316544@gmail.com
web: general refactor (#158)..., 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.Utils
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
        [ Components.Utils.viewIf note.burnBeforeExpiration
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.Utils.loadSvg { path = "warning.svg", class = "w-4 h-4 text-orange-600" } ]
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
        , 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: " ++ T.toString zone note.createdAt) ]
218
                        , Components.Utils.viewMaybe note.expiresAt (\n -> H.p [] [ H.text ("Expires at: " ++ T.toString zone n) ])
219
                        ]
220
                    ]
221
                , H.div [ A.class "flex gap-2" ]
222
                    [ H.button
223
                        [ E.onClick UserClickedCopyContent
224
                        , 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"
225
                        ]
226
                        [ H.text "Copy Content" ]
227
                    ]
228
                ]
229
            ]
230
        ]
231
232
233
234
-- NOTE
235
236
237
viewNoteNotFound : String -> Html msg
238
viewNoteNotFound slug =
239
    H.div [ A.class "p-6" ]
240
        [ H.div [ A.class "text-center py-12" ]
241
            [ H.div [ A.class "w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4" ]
242
                [ Components.Utils.loadSvg { path = "note-not-found.svg", class = "w-8 h-8 text-red-500" } ]
243
            , H.h2 [ A.class "text-xl font-semibold text-gray-900 mb-2" ]
244
                [ H.text ("Note " ++ slug ++ " Not Found") ]
245
            , H.div [ A.class "text-gray-600 mb-6 space-y-2" ]
246
                [ H.p []
247
                    [ H.span [ A.class "font-bold" ] [ H.text "This note may have:" ]
248
                    , H.ul [ A.class "text-sm space-y-1 list-disc list-inside text-left max-w-md mx-auto" ]
249
                        [ H.li [] [ H.text "Expired and been deleted" ]
250
                        , H.li [] [ H.text "Have different password" ]
251
                        , H.li [] [ H.text "Been deleted by the creator" ]
252
                        , H.li [] [ H.text "Been burned after reading" ]
253
                        , H.li [] [ H.text "Never existed or the URL is incorrect" ]
254
                        ]
255
                    ]
256
                ]
257
            ]
258
        ]
259
260
261
viewOpenNote : { slug : String, hasPassword : Bool, isLoading : Bool, password : Maybe String } -> Html Msg
262
viewOpenNote opts =
263
    let
264
        isDisabled =
265
            opts.hasPassword && Maybe.withDefault "" opts.password == ""
266
267
        buttonData =
268
            let
269
                base =
270
                    "px-6 py-3 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors"
271
            in
272
            if opts.isLoading then
273
                { text = "Loading Note...", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" }
274
275
            else if isDisabled then
276
                { text = "View Note", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" }
277
278
            else
279
                { text = "View Note", class = base ++ " bg-black text-white hover:bg-gray-800" }
280
    in
281
    H.div [ A.class "p-6" ]
282
        [ H.div [ A.class "text-center py-12" ]
283
            [ H.div [ A.class "mb-6" ]
284
                [ H.div [ A.class "w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4" ]
285
                    [ Components.Utils.loadSvg { path = "note-icon.svg", class = "w-8 h-8 text-gray-400" } ]
286
                , H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-2" ] [ H.text opts.slug ]
287
                , H.p [ A.class "text-gray-600 mb-6" ] [ H.text "You're about read and destroy the note." ]
288
                ]
289
            , H.form
290
                [ E.onSubmit UserClickedViewNote
291
                , A.class "max-w-sm mx-auto space-y-4"
292
                ]
293
                [ Components.Utils.viewIf opts.hasPassword
294
                    (H.div
295
                        [ A.class "space-y-2" ]
296
                        [ H.label
297
                            [ A.class "block text-sm font-medium text-gray-700 text-left" ]
298
                            [ H.text "Password" ]
299
                        , H.input
300
                            [ E.onInput UserUpdatedPassword
301
                            , 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"
302
                            ]
303
                            []
304
                        ]
305
                    )
306
                , H.button
307
                    [ A.class buttonData.class
308
                    , A.type_ "submit"
309
                    , A.disabled isDisabled
310
                    ]
311
                    [ H.text buttonData.text ]
312
                ]
313
            ]
314
        ]
315
316
317
viewNoteContent : Note -> Html msg
318
viewNoteContent note =
319
    H.div [ A.class "p-6" ]
320
        [ H.div [ A.class "bg-gray-50 border border-gray-200 rounded-md p-4" ]
321
            [ H.pre
322
                [ A.class "whitespace-pre-wrap font-mono text-sm text-gray-800 overflow-x-auto" ]
323
                [ H.text note.content ]
324
            ]
325
        ]