all repos

onasty @ 62e4dde75f5cca100c807673176826e5f120c86d

a one-time notes service
8 files changed, 527 insertions(+), 21 deletions(-)
web: show note (#147)

* web: add svg for note

* web: start working on show note page

* web: api get note

* web: save note (wip)

* web: fetch note's metadata

* web: handle all cases

* web: fix response decoder

* ake elm-review happy

* web: fix styling for show note

* web: show warning that note will be burn

* web: refactor

* refactor(web): Maybe Bool is funny

* feat(web): add password field to model

* update not found message

* that's broken but i gotta refactor the whole page

* refactor(web): change how secrets page logic is handled

* fixup! refactor(web): change how secrets page logic is handled

* run elm-review --fix

* refactor: validate password on input

* refactor(web): update api sdk to get the note with password if one is
provided

* refactor(web): make api sdk methods naming more consistent

* fixup! refactor(web): make api sdk methods naming more consistent

* fixup! run elm-review --fix

* web: if metadata is 404 show 404 page

* refactor(web): naming, keep the div centered
Author: Smirnov Olexandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-07-01 13:25:47 +0300
Parent: 1994b67
M web/elm.json
···
        12
        12
                     "elm/html": "1.0.0",

      
        13
        13
                     "elm/http": "2.0.0",

      
        14
        14
                     "elm/json": "1.1.3",

      
        
        15
        +            "elm/svg": "1.0.1",

      
        15
        16
                     "elm/time": "1.0.0",

      
        16
        17
                     "elm/url": "1.0.0",

      
        17
        18
                     "jweir/elm-iso8601": "7.0.1",

      
M web/src/Api.elm
···
        1
        
        -module Api exposing (Error(..), Response(..), errorMessage)

      
        
        1
        +module Api exposing (Error(..), Response(..), errorMessage, is404)

      
        2
        2
         

      
        3
        3
         import Http

      
        4
        4
         import Json.Decode

      ···
        29
        29
         

      
        30
        30
                 JsonDecodeError err ->

      
        31
        31
                     err.message

      
        
        32
        +

      
        
        33
        +

      
        
        34
        +is404 : Error -> Bool

      
        
        35
        +is404 error =

      
        
        36
        +    case error of

      
        
        37
        +        HttpError { reason } ->

      
        
        38
        +            reason == Http.BadStatus 404

      
        
        39
        +

      
        
        40
        +        _ ->

      
        
        41
        +            False

      
M web/src/Api/Note.elm
···
        1
        
        -module Api.Note exposing (create)

      
        
        1
        +module Api.Note exposing (create, get, getMetadata)

      
        2
        2
         

      
        3
        3
         import Api

      
        4
        
        -import Data.Note as Note exposing (CreateResponse)

      
        
        4
        +import Data.Note as Note exposing (CreateResponse, Metadata, Note)

      
        5
        5
         import Effect exposing (Effect)

      
        6
        6
         import Http

      
        7
        7
         import ISO8601

      
        8
        8
         import Json.Encode as E

      
        9
        9
         import Time exposing (Posix)

      
        
        10
        +import Url

      
        10
        11
         

      
        11
        12
         

      
        12
        13
         create :

      ···
        20
        21
             -> Effect msg

      
        21
        22
         create options =

      
        22
        23
             let

      
        
        24
        +        encodeMaybe : Maybe a -> b -> (a -> E.Value) -> ( b, E.Value )

      
        
        25
        +        encodeMaybe maybeData field value =

      
        
        26
        +            case maybeData of

      
        
        27
        +                Just data ->

      
        
        28
        +                    ( field, value data )

      
        
        29
        +

      
        
        30
        +                Nothing ->

      
        
        31
        +                    ( field, E.null )

      
        
        32
        +

      
        23
        33
                 body : E.Value

      
        24
        34
                 body =

      
        25
        35
                     E.object

      
        26
        36
                         [ ( "content", E.string options.content )

      
        27
        
        -                , case options.slug of

      
        28
        
        -                    Just slug ->

      
        29
        
        -                        ( "slug", E.string slug )

      
        30
        
        -

      
        31
        
        -                    Nothing ->

      
        32
        
        -                        ( "slug", E.null )

      
        33
        
        -                , case options.password of

      
        34
        
        -                    Just password ->

      
        35
        
        -                        ( "password", E.string password )

      
        36
        
        -

      
        37
        
        -                    Nothing ->

      
        38
        
        -                        ( "password", E.null )

      
        
        37
        +                , encodeMaybe options.slug "slug" E.string

      
        
        38
        +                , encodeMaybe options.password "password" E.string

      
        39
        39
                         , ( "burn_before_expiration", E.bool options.burnBeforeExpiration )

      
        40
        40
                         , if options.expiresAt == Time.millisToPosix 0 then

      
        41
        41
                             ( "expires_at", E.null )

      ···
        56
        56
                 , onResponse = options.onResponse

      
        57
        57
                 , decoder = Note.decodeCreateResponse

      
        58
        58
                 }

      
        
        59
        +

      
        
        60
        +

      
        
        61
        +get :

      
        
        62
        +    { onResponse : Result Api.Error Note -> msg

      
        
        63
        +    , slug : String

      
        
        64
        +    , password : Maybe String

      
        
        65
        +    }

      
        
        66
        +    -> Effect msg

      
        
        67
        +get options =

      
        
        68
        +    Effect.sendApiRequest

      
        
        69
        +        { endpoint =

      
        
        70
        +            "/api/v1/note/"

      
        
        71
        +                ++ options.slug

      
        
        72
        +                ++ (case options.password of

      
        
        73
        +                        Just p ->

      
        
        74
        +                            "?password=" ++ Url.percentEncode p

      
        
        75
        +

      
        
        76
        +                        Nothing ->

      
        
        77
        +                            ""

      
        
        78
        +                   )

      
        
        79
        +        , method = "GET"

      
        
        80
        +        , body = Http.emptyBody

      
        
        81
        +        , onResponse = options.onResponse

      
        
        82
        +        , decoder = Note.decode

      
        
        83
        +        }

      
        
        84
        +

      
        
        85
        +

      
        
        86
        +getMetadata :

      
        
        87
        +    { onResponse : Result Api.Error Metadata -> msg

      
        
        88
        +    , slug : String

      
        
        89
        +    }

      
        
        90
        +    -> Effect msg

      
        
        91
        +getMetadata options =

      
        
        92
        +    Effect.sendApiRequest

      
        
        93
        +        { endpoint = "/api/v1/note/" ++ options.slug ++ "/meta"

      
        
        94
        +        , method = "GET"

      
        
        95
        +        , body = Http.emptyBody

      
        
        96
        +        , onResponse = options.onResponse

      
        
        97
        +        , decoder = Note.decodeMetadata

      
        
        98
        +        }

      
A web/src/Components/Note.elm
···
        
        1
        +module Components.Note exposing (noteIconSvg, noteNotFoundSvg, warningSvg)

      
        
        2
        +

      
        
        3
        +import Svg exposing (Svg)

      
        
        4
        +import Svg.Attributes as A

      
        
        5
        +

      
        
        6
        +

      
        
        7
        +noteIconSvg : Svg msg

      
        
        8
        +noteIconSvg =

      
        
        9
        +    Svg.svg

      
        
        10
        +        [ A.class "w-8 h-8 text-gray-400"

      
        
        11
        +        , A.fill "none"

      
        
        12
        +        , A.stroke "currentColor"

      
        
        13
        +        , A.viewBox "0 0 24 24"

      
        
        14
        +        ]

      
        
        15
        +        [ Svg.path

      
        
        16
        +            [ A.d "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"

      
        
        17
        +            , A.strokeWidth "2"

      
        
        18
        +            , A.strokeLinecap "round"

      
        
        19
        +            , A.strokeLinejoin "round"

      
        
        20
        +            ]

      
        
        21
        +            []

      
        
        22
        +        ]

      
        
        23
        +

      
        
        24
        +

      
        
        25
        +noteNotFoundSvg : Svg msg

      
        
        26
        +noteNotFoundSvg =

      
        
        27
        +    Svg.svg

      
        
        28
        +        [ A.class "w-8 h-8 text-red-500"

      
        
        29
        +        , A.fill "none"

      
        
        30
        +        , A.stroke "currentColor"

      
        
        31
        +        , A.viewBox "0 0 24 24"

      
        
        32
        +        ]

      
        
        33
        +        [ Svg.path

      
        
        34
        +            [ A.d "M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"

      
        
        35
        +            , A.strokeWidth "2"

      
        
        36
        +            , A.strokeLinecap "round"

      
        
        37
        +            , A.strokeLinejoin "round"

      
        
        38
        +            ]

      
        
        39
        +            []

      
        
        40
        +        , Svg.path

      
        
        41
        +            [ A.d "M6 18L18 6M6 6l12 12"

      
        
        42
        +            , A.strokeWidth "2"

      
        
        43
        +            , A.strokeLinecap "round"

      
        
        44
        +            , A.strokeLinejoin "round"

      
        
        45
        +            ]

      
        
        46
        +            []

      
        
        47
        +        ]

      
        
        48
        +

      
        
        49
        +

      
        
        50
        +warningSvg : Svg msg

      
        
        51
        +warningSvg =

      
        
        52
        +    Svg.svg

      
        
        53
        +        [ A.class "w-4 h-4 text-orange-600"

      
        
        54
        +        , A.fill "none"

      
        
        55
        +        , A.stroke "currentColor"

      
        
        56
        +        , A.viewBox "0 0 24 24"

      
        
        57
        +        ]

      
        
        58
        +        [ Svg.path

      
        
        59
        +            [ A.d "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z"

      
        
        60
        +            , A.strokeWidth "2"

      
        
        61
        +            , A.strokeLinecap "round"

      
        
        62
        +            , A.strokeLinejoin "round"

      
        
        63
        +            ]

      
        
        64
        +            []

      
        
        65
        +        ]

      
M web/src/Data/Note.elm
···
        1
        
        -module Data.Note exposing (CreateResponse, decodeCreateResponse)

      
        
        1
        +module Data.Note exposing (CreateResponse, Metadata, Note, decode, decodeCreateResponse, decodeMetadata)

      
        2
        2
         

      
        3
        3
         import Json.Decode as D exposing (Decoder)

      
        4
        4
         

      ···
        11
        11
         decodeCreateResponse =

      
        12
        12
             D.map CreateResponse

      
        13
        13
                 (D.field "slug" D.string)

      
        
        14
        +

      
        
        15
        +

      
        
        16
        +type alias Note =

      
        
        17
        +    { content : String

      
        
        18
        +    , readAt : Maybe String -- TODO: use Posix

      
        
        19
        +    , burnBeforeExpiration : Bool

      
        
        20
        +    , createdAt : String -- TODO: use Posix

      
        
        21
        +    , expiresAt : Maybe String -- TODO: use Posix

      
        
        22
        +    }

      
        
        23
        +

      
        
        24
        +

      
        
        25
        +decode : Decoder Note

      
        
        26
        +decode =

      
        
        27
        +    D.map5 Note

      
        
        28
        +        (D.field "content" D.string)

      
        
        29
        +        (D.maybe (D.field "read_at" D.string))

      
        
        30
        +        (D.field "burn_before_expiration" D.bool)

      
        
        31
        +        (D.field "created_at" D.string)

      
        
        32
        +        (D.maybe (D.field "expires_at" D.string))

      
        
        33
        +

      
        
        34
        +

      
        
        35
        +type alias Metadata =

      
        
        36
        +    { createdAt : String -- TODO: use Posix

      
        
        37
        +    , hasPassword : Bool

      
        
        38
        +    }

      
        
        39
        +

      
        
        40
        +

      
        
        41
        +decodeMetadata : Decoder Metadata

      
        
        42
        +decodeMetadata =

      
        
        43
        +    D.map2 Metadata

      
        
        44
        +        (D.field "created_at" D.string)

      
        
        45
        +        (D.field "has_password" D.bool)

      
M web/src/Effect.elm
···
        377
        377
                             Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err })

      
        378
        378
         

      
        379
        379
                 Http.BadStatus_ { statusCode } body ->

      
        380
        
        -            case Json.Decode.decodeString Data.Error.decode body of

      
        381
        
        -                Ok err ->

      
        382
        
        -                    Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode })

      
        
        380
        +            case body of

      
        
        381
        +                "" ->

      
        
        382
        +                    Err (Api.HttpError { message = "Unexpected empty response", reason = Http.BadStatus statusCode })

      
        
        383
        +

      
        
        384
        +                _ ->

      
        
        385
        +                    case Json.Decode.decodeString Data.Error.decode body of

      
        
        386
        +                        Ok err ->

      
        
        387
        +                            Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode })

      
        383
        388
         

      
        384
        
        -                Err err ->

      
        385
        
        -                    Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err })

      
        
        389
        +                        Err err ->

      
        
        390
        +                            Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err })

      
        386
        391
         

      
        387
        392
                 Http.BadUrl_ url ->

      
        388
        393
                     Err (Api.HttpError { message = "Unexpected URL format", reason = Http.BadUrl url })

      
A web/src/Pages/Secret/Slug_.elm
···
        
        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 : Bool

      
        
        277
        +        isDisabled =

      
        
        278
        +            opts.hasPassword && Maybe.withDefault "" opts.password == ""

      
        
        279
        +

      
        
        280
        +        buttonData : { text : String, class : String }

      
        
        281
        +        buttonData =

      
        
        282
        +            let

      
        
        283
        +                base : String

      
        
        284
        +                base =

      
        
        285
        +                    "px-6 py-3 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors"

      
        
        286
        +            in

      
        
        287
        +            if opts.isLoading then

      
        
        288
        +                { text = "Loading Note...", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" }

      
        
        289
        +

      
        
        290
        +            else if isDisabled then

      
        
        291
        +                { text = "View Note", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" }

      
        
        292
        +

      
        
        293
        +            else

      
        
        294
        +                { text = "View Note", class = base ++ " bg-black text-white hover:bg-gray-800" }

      
        
        295
        +    in

      
        
        296
        +    H.div [ A.class "p-6" ]

      
        
        297
        +        [ H.div [ A.class "text-center py-12" ]

      
        
        298
        +            [ H.div [ A.class "mb-6" ]

      
        
        299
        +                [ H.div [ A.class "w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4" ]

      
        
        300
        +                    [ Components.Note.noteIconSvg ]

      
        
        301
        +                , H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-2" ] [ H.text opts.slug ]

      
        
        302
        +                , H.p [ A.class "text-gray-600 mb-6" ] [ H.text "You're about read and destroy the note." ]

      
        
        303
        +                ]

      
        
        304
        +            , H.form

      
        
        305
        +                [ E.onSubmit UserClickedViewNote

      
        
        306
        +                , A.class "max-w-sm mx-auto space-y-4"

      
        
        307
        +                ]

      
        
        308
        +                [ if opts.hasPassword then

      
        
        309
        +                    H.div

      
        
        310
        +                        [ A.class "space-y-2" ]

      
        
        311
        +                        [ H.label

      
        
        312
        +                            [ A.class "block text-sm font-medium text-gray-700 text-left" ]

      
        
        313
        +                            [ H.text "Password" ]

      
        
        314
        +                        , H.input

      
        
        315
        +                            [ E.onInput UserUpdatedPassword

      
        
        316
        +                            , 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"

      
        
        317
        +                            ]

      
        
        318
        +                            []

      
        
        319
        +                        ]

      
        
        320
        +

      
        
        321
        +                  else

      
        
        322
        +                    H.text ""

      
        
        323
        +                , H.button

      
        
        324
        +                    [ A.class buttonData.class

      
        
        325
        +                    , A.type_ "submit"

      
        
        326
        +                    , A.disabled isDisabled

      
        
        327
        +                    ]

      
        
        328
        +                    [ H.text buttonData.text ]

      
        
        329
        +                ]

      
        
        330
        +            ]

      
        
        331
        +        ]

      
        
        332
        +

      
        
        333
        +

      
        
        334
        +viewNoteContent : Note -> Html msg

      
        
        335
        +viewNoteContent note =

      
        
        336
        +    H.div [ A.class "p-6" ]

      
        
        337
        +        [ H.div [ A.class "bg-gray-50 border border-gray-200 rounded-md p-4" ]

      
        
        338
        +            [ H.pre

      
        
        339
        +                [ A.class "whitespace-pre-wrap font-mono text-sm text-gray-800 overflow-x-auto" ]

      
        
        340
        +                [ H.text note.content ]

      
        
        341
        +            ]

      
        
        342
        +        ]

      
M web/tests/UnitTests/Data/Note.elm
···
        15
        15
                             |> D.decodeString Data.Note.decodeCreateResponse

      
        16
        16
                             |> Expect.equal (Ok { slug = "the.note-slug" })

      
        17
        17
                     )

      
        
        18
        +        , test "decodeMetadata"

      
        
        19
        +            (\_ ->

      
        
        20
        +                """

      
        
        21
        +                    {

      
        
        22
        +                        "created_at": "2023-10-01T12:00:00Z",

      
        
        23
        +                        "has_password": false

      
        
        24
        +                    }

      
        
        25
        +                    """

      
        
        26
        +                    |> D.decodeString Data.Note.decodeMetadata

      
        
        27
        +                    |> Expect.equal (Ok { createdAt = "2023-10-01T12:00:00Z", hasPassword = False })

      
        
        28
        +            )

      
        18
        29
                 ]