all repos

onasty @ f537564

a one-time notes service
19 files changed, 247 insertions(+), 316 deletions(-)
web: general refactor (#158)

* refactor(web): unify input component

* web: make the input component even more complex

* refactor(web): update comments, type annotations, formatting

- the aim of the commit is to reduce the about of LOCs of the project

* web: dont show note password when typed in

* web: refactor how header is shown

* refactor(web): change way reminding time before user can resend the
veification email

* refactor web

* refactor(web): add viewMaybe, and viewIf helpers

* fixup! web: refactor how header is shown

* fixup! refactor(web): add viewMaybe, and viewIf helpers

* fixup! fixup! refactor(web): add viewMaybe, and viewIf helpers

* refactor(web): header couldn't care less

The header cares only if user is authorized, all other cases are
considered to be unauthorized state

* web: i like this formatting more

* web: there's no need in this doc comment

* refactor(web): serve svgs from static file, and not elm code

* add TODO comments
Author: Olexandr Smirnov ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-07-07 12:52:31 +0300
Parent: f6a62ba
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",

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

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

      
        18
        17
                     "rtfeldman/elm-iso8601-date-strings": "1.1.4",

      
M web/src/Api/Auth.elm
···
        63
        63
             -> Effect msg

      
        64
        64
         refreshToken options =

      
        65
        65
             let

      
        66
        
        -        body : Encode.Value

      
        67
        66
                 body =

      
        68
        
        -            Encode.object

      
        69
        
        -                [ ( "refresh_token", Encode.string options.refreshToken ) ]

      
        
        67
        +            Encode.object [ ( "refresh_token", Encode.string options.refreshToken ) ]

      
        70
        68
             in

      
        71
        69
             Effect.sendApiRequest

      
        72
        70
                 { endpoint = "/api/v1/auth/refresh-tokens"

      
M web/src/Api/Note.elm
···
        21
        21
             -> Effect msg

      
        22
        22
         create options =

      
        23
        23
             let

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

      
        25
        
        -        encodeMaybe maybeData field value =

      
        26
        
        -            case maybeData of

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

      
        
        25
        +        encodeMaybe maybe field value =

      
        
        26
        +            case maybe of

      
        27
        27
                         Just data ->

      
        28
        28
                             ( field, value data )

      
        29
        29
         

      
        30
        30
                         Nothing ->

      
        31
        31
                             ( field, E.null )

      
        32
        32
         

      
        33
        
        -        body : E.Value

      
        34
        33
                 body =

      
        35
        34
                     E.object

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

      ···
        41
        40
                             ( "expires_at", E.null )

      
        42
        41
         

      
        43
        42
                           else

      
        44
        
        -                    ( "expires_at"

      
        45
        
        -                    , options.expiresAt

      
        46
        
        -                        |> Iso8601.fromTime

      
        47
        
        -                        |> E.string

      
        48
        
        -                    )

      
        
        43
        +                    ( "expires_at", options.expiresAt |> Iso8601.fromTime |> E.string )

      
        49
        44
                         ]

      
        50
        45
             in

      
        51
        46
             Effect.sendApiRequest

      
A web/src/Components/Form.elm
···
        
        1
        +module Components.Form exposing (input)

      
        
        2
        +

      
        
        3
        +import Html as H exposing (Html)

      
        
        4
        +import Html.Attributes as A

      
        
        5
        +import Html.Events as E

      
        
        6
        +

      
        
        7
        +

      
        
        8
        +input :

      
        
        9
        +    -- TODO: add `error : Maybe String`, to input to show that field is not correct and message

      
        
        10
        +    { id : String

      
        
        11
        +    , field : field

      
        
        12
        +    , label : String

      
        
        13
        +    , type_ : String

      
        
        14
        +    , value : String

      
        
        15
        +    , placeholder : String

      
        
        16
        +    , required : Bool

      
        
        17
        +    , helpText : Maybe String

      
        
        18
        +    , prefix : Maybe String

      
        
        19
        +    , onInput : String -> msg

      
        
        20
        +    }

      
        
        21
        +    -> Html msg

      
        
        22
        +input opts =

      
        
        23
        +    H.div [ A.class "space-y-2" ]

      
        
        24
        +        [ H.label

      
        
        25
        +            [ A.for opts.id

      
        
        26
        +            , A.class "block text-sm font-medium text-gray-700"

      
        
        27
        +            ]

      
        
        28
        +            [ H.text opts.label ]

      
        
        29
        +        , H.div

      
        
        30
        +            [ A.class

      
        
        31
        +                (if opts.prefix /= Nothing then

      
        
        32
        +                    "flex items-center"

      
        
        33
        +

      
        
        34
        +                 else

      
        
        35
        +                    ""

      
        
        36
        +                )

      
        
        37
        +            ]

      
        
        38
        +            [ case opts.prefix of

      
        
        39
        +                Just prefix ->

      
        
        40
        +                    H.span [ A.class "text-gray-500 text-md mr-2 whitespace-nowrap" ] [ H.text prefix ]

      
        
        41
        +

      
        
        42
        +                Nothing ->

      
        
        43
        +                    H.text ""

      
        
        44
        +            , H.input

      
        
        45
        +                [ 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"

      
        
        46
        +                , A.type_ opts.type_

      
        
        47
        +                , A.value opts.value

      
        
        48
        +                , A.id opts.id

      
        
        49
        +                , A.placeholder opts.placeholder

      
        
        50
        +                , A.required opts.required

      
        
        51
        +                , E.onInput opts.onInput

      
        
        52
        +                ]

      
        
        53
        +                []

      
        
        54
        +            ]

      
        
        55
        +        , case opts.helpText of

      
        
        56
        +            Just help ->

      
        
        57
        +                H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text help ]

      
        
        58
        +

      
        
        59
        +            Nothing ->

      
        
        60
        +                H.text ""

      
        
        61
        +        ]

      
D 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
        
        -        ]

      
A web/src/Components/Utils.elm
···
        
        1
        +module Components.Utils exposing (loadSvg, viewIf, viewMaybe)

      
        
        2
        +

      
        
        3
        +import Html as H exposing (Html)

      
        
        4
        +import Html.Attributes as A

      
        
        5
        +

      
        
        6
        +

      
        
        7
        +viewIf : Bool -> Html msg -> Html msg

      
        
        8
        +viewIf condition html =

      
        
        9
        +    if condition then

      
        
        10
        +        html

      
        
        11
        +

      
        
        12
        +    else

      
        
        13
        +        H.text ""

      
        
        14
        +

      
        
        15
        +

      
        
        16
        +viewMaybe : Maybe a -> (a -> Html msg) -> Html msg

      
        
        17
        +viewMaybe maybeValue toHtml =

      
        
        18
        +    case maybeValue of

      
        
        19
        +        Just value ->

      
        
        20
        +            toHtml value

      
        
        21
        +

      
        
        22
        +        Nothing ->

      
        
        23
        +            H.text ""

      
        
        24
        +

      
        
        25
        +

      
        
        26
        +loadSvg : { path : String, class : String } -> Html msg

      
        
        27
        +loadSvg { path, class } =

      
        
        28
        +    H.img [ A.src ("/static/" ++ path), A.class class ] []

      
M web/src/Data/Credentials.elm
···
        1
        
        -module Data.Credentials exposing

      
        2
        
        -    ( Credentials

      
        3
        
        -    , decode

      
        4
        
        -    )

      
        5
        
        -

      
        6
        
        -{-|

      
        7
        
        -

      
        8
        
        -@docs Credentials

      
        9
        
        -@docs decode

      
        10
        
        -

      
        11
        
        --}

      
        
        1
        +module Data.Credentials exposing (Credentials, decode)

      
        12
        2
         

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

      
        14
        4
         

      
M web/src/Effect.elm
···
        1
        1
         module Effect exposing

      
        2
        
        -    ( Effect

      
        3
        
        -    , none, batch

      
        4
        
        -    , sendCmd, sendMsg

      
        5
        
        -    , pushRoute, replaceRoute

      
        6
        
        -    , pushRoutePath, replaceRoutePath

      
        7
        
        -    , loadExternalUrl, back

      
        8
        
        -    , sendApiRequest, refreshTokens

      
        9
        
        -    , sendToClipboard

      
        10
        
        -    , signin, logout, saveUser, clearUser

      
        
        2
        +    ( Effect, none, batch, sendCmd, sendMsg

      
        
        3
        +    , pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back

      
        
        4
        +    , sendApiRequest, sendToClipboard

      
        
        5
        +    , signin, logout, refreshTokens, saveUser, clearUser

      
        11
        6
             , map, toCmd

      
        12
        7
             )

      
        13
        8
         

      
        14
        9
         {-|

      
        15
        10
         

      
        16
        
        -@docs Effect

      
        17
        
        -

      
        18
        
        -@docs none, batch

      
        19
        
        -@docs sendCmd, sendMsg

      
        20
        
        -

      
        21
        
        -@docs pushRoute, replaceRoute

      
        22
        
        -@docs pushRoutePath, replaceRoutePath

      
        23
        
        -@docs loadExternalUrl, back

      
        24
        
        -

      
        25
        
        -@docs sendApiRequest, refreshTokens

      
        26
        
        -@docs sendToClipboard

      
        27
        
        -@docs signin, logout, saveUser, clearUser

      
        28
        
        -

      
        
        11
        +@docs Effect, none, batch, sendCmd, sendMsg

      
        
        12
        +@docs pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back

      
        
        13
        +@docs sendApiRequest, sendToClipboard

      
        
        14
        +@docs signin, logout, refreshTokens, saveUser, clearUser

      
        29
        15
         @docs map, toCmd

      
        30
        16
         

      
        31
        17
         -}

      ···
        62
        48
             | SendSharedMsg Shared.Msg.Msg

      
        63
        49
             | SendToLocalStorage { key : String, value : Json.Encode.Value }

      
        64
        50
             | SendToClipboard String

      
        65
        
        -    | SendApiRequest (HttpRequestDetails msg)

      
        66
        
        -

      
        67
        
        -

      
        68
        
        -type alias HttpRequestDetails msg =

      
        69
        
        -    { endpoint : String

      
        70
        
        -    , method : String

      
        71
        
        -    , body : Http.Body

      
        72
        
        -    , decoder : Json.Decode.Decoder msg

      
        73
        
        -    , onHttpError : Api.Error -> msg

      
        74
        
        -    }

      
        
        51
        +    | SendApiRequest

      
        
        52
        +        { endpoint : String

      
        
        53
        +        , method : String

      
        
        54
        +        , body : Http.Body

      
        
        55
        +        , decoder : Json.Decode.Decoder msg

      
        
        56
        +        , onHttpError : Api.Error -> msg

      
        
        57
        +        }

      
        75
        58
         

      
        76
        59
         

      
        77
        60
         

      ···
        230
        213
         -- INTERNALS

      
        231
        214
         

      
        232
        215
         

      
        233
        
        -{-| Elm Land depends on this function to connect pages and layouts

      
        234
        
        -together into the overall app.

      
        235
        
        --}

      
        236
        216
         map : (msg1 -> msg2) -> Effect msg1 -> Effect msg2

      
        237
        217
         map fn effect =

      
        238
        218
             case effect of

      ···
        276
        256
                         }

      
        277
        257
         

      
        278
        258
         

      
        279
        
        -{-| Elm Land depends on this function to perform your effects.

      
        280
        
        --}

      
        281
        259
         toCmd :

      
        282
        260
             { key : Browser.Navigation.Key

      
        283
        261
             , url : Url

      ···
        351
        329
                                         Err err ->

      
        352
        330
                                             opts.onHttpError err

      
        353
        331
                                 )

      
        354
        
        -                        (\resp -> fromHttpResponseToCustomError opts.decoder resp)

      
        
        332
        +                        (\resp -> httpResponseToCustomError opts.decoder resp)

      
        355
        333
                         , timeout = Just (1000 * 60) -- 60 second timeout

      
        356
        334
                         , tracker = Nothing

      
        357
        335
                         }

      
        358
        336
         

      
        359
        337
         

      
        360
        
        -fromHttpResponseToCustomError : Json.Decode.Decoder msg -> Http.Response String -> Result Api.Error msg

      
        361
        
        -fromHttpResponseToCustomError decoder response =

      
        
        338
        +httpResponseToCustomError : Json.Decode.Decoder msg -> Http.Response String -> Result Api.Error msg

      
        
        339
        +httpResponseToCustomError decoder response =

      
        362
        340
             case response of

      
        363
        341
                 Http.GoodStatus_ _ body ->

      
        364
        342
                     case

      
M web/src/ExpirationOptions.elm
···
        1
        
        -module ExpirationOptions exposing (ExpiresAt, expirationOptions)

      
        2
        
        -

      
        3
        
        -

      
        4
        
        -type alias ExpiresAt =

      
        5
        
        -    { text : String, value : Int }

      
        
        1
        +module ExpirationOptions exposing (expirationOptions)

      
        6
        2
         

      
        7
        3
         

      
        8
        
        -expirationOptions : List ExpiresAt

      
        
        4
        +expirationOptions : List { text : String, value : Int }

      
        9
        5
         expirationOptions =

      
        10
        6
             [ { text = "Never expires (default)", value = 0 }

      
        11
        7
             , { text = "1 hour", value = 60 * 60 * 1000 }

      
M web/src/JwtUtil.elm
···
        9
        9
         isExpired : Time.Posix -> String -> Bool

      
        10
        10
         isExpired now token =

      
        11
        11
             let

      
        12
        
        -        expirationThreshold : number

      
        13
        12
                 expirationThreshold =

      
        14
        13
                     40 * 1000

      
        15
        14
         

      
        16
        
        -        timeDiff : Int

      
        17
        15
                 timeDiff =

      
        18
        16
                     getTokenExpiration token

      
        19
        17
                         |> (\expiration -> expiration - Time.posixToMillis now)

      ···
        21
        19
             timeDiff <= expirationThreshold

      
        22
        20
         

      
        23
        21
         

      
        24
        
        -{-| Extracts the expiration time (in millis) from a JWT token.

      
        25
        
        -Returns 0 if cannot parse token.

      
        26
        
        --}

      
        27
        22
         getTokenExpiration : String -> Int

      
        28
        23
         getTokenExpiration token =

      
        29
        
        -    Jwt.getTokenExpirationMillis token

      
        30
        
        -        |> Result.withDefault 0

      
        
        24
        +    Jwt.getTokenExpirationMillis token |> Result.withDefault 0

      
M web/src/Layouts/Header.elm
···
        93
        93
         

      
        94
        94
         viewNav : Auth.User.SignInStatus -> List (Html Msg)

      
        95
        95
         viewNav user =

      
        
        96
        +    let

      
        
        97
        +        viewLink text path =

      
        
        98
        +            H.a [ A.class "text-gray-600 hover:text-black transition-colors", Route.Path.href path ]

      
        
        99
        +                [ H.text text ]

      
        
        100
        +    in

      
        96
        101
             case user of

      
        97
        102
                 Auth.User.SignedIn _ ->

      
        98
        
        -            viewSignedInNav

      
        99
        
        -

      
        100
        
        -        Auth.User.NotSignedIn ->

      
        101
        
        -            viewNotSignedInNav

      
        102
        
        -

      
        103
        
        -        Auth.User.RefreshingTokens ->

      
        104
        
        -            viewNotSignedInNav

      
        105
        
        -

      
        106
        
        -

      
        107
        
        -viewSignedInNav : List (Html Msg)

      
        108
        
        -viewSignedInNav =

      
        109
        
        -    [ viewLink "Profile" Route.Path.Profile_Me

      
        110
        
        -    , H.button

      
        111
        
        -        [ A.class "text-gray-600 hover:text-red-600 transition-colors"

      
        112
        
        -        , E.onClick UserClickedLogout

      
        113
        
        -        ]

      
        114
        
        -        [ H.text "Logout" ]

      
        115
        
        -    ]

      
        116
        
        -

      
        117
        
        -

      
        118
        
        -viewNotSignedInNav : List (Html Msg)

      
        119
        
        -viewNotSignedInNav =

      
        120
        
        -    -- TODO: or add about page, or delete the link

      
        121
        
        -    [ viewLink "About" Route.Path.Home_

      
        122
        
        -    , H.a

      
        123
        
        -        [ A.class "px-4 py-2 border border-gray-300 rounded-md text-black hover:bg-gray-50 transition-colors"

      
        124
        
        -        , Route.Path.href Route.Path.Auth

      
        125
        
        -        ]

      
        126
        
        -        [ H.text "Sign In/Up" ]

      
        127
        
        -    ]

      
        
        103
        +            [ viewLink "Profile" Route.Path.Profile_Me

      
        
        104
        +            , H.button

      
        
        105
        +                [ A.class "text-gray-600 hover:text-red-600 transition-colors"

      
        
        106
        +                , E.onClick UserClickedLogout

      
        
        107
        +                ]

      
        
        108
        +                [ H.text "Logout" ]

      
        
        109
        +            ]

      
        128
        110
         

      
        129
        
        -

      
        130
        
        -viewLink : String -> Route.Path.Path -> Html Msg

      
        131
        
        -viewLink text path =

      
        132
        
        -    H.a

      
        133
        
        -        [ A.class "text-gray-600 hover:text-black transition-colors"

      
        134
        
        -        , Route.Path.href path

      
        135
        
        -        ]

      
        136
        
        -        [ H.text text ]

      
        
        111
        +        _ ->

      
        
        112
        +            [ viewLink "About" Route.Path.Home_ -- TODO: or add about page, or delete the link

      
        
        113
        +            , H.a

      
        
        114
        +                [ A.class "px-4 py-2 border border-gray-300 rounded-md text-black hover:bg-gray-50 transition-colors"

      
        
        115
        +                , Route.Path.href Route.Path.Auth

      
        
        116
        +                ]

      
        
        117
        +                [ H.text "Sign In/Up" ]

      
        
        118
        +            ]

      
M web/src/Pages/Auth.elm
···
        4
        4
         import Api.Auth

      
        5
        5
         import Auth.User

      
        6
        6
         import Components.Error

      
        
        7
        +import Components.Form

      
        
        8
        +import Components.Utils

      
        7
        9
         import Data.Credentials exposing (Credentials)

      
        8
        10
         import Effect exposing (Effect)

      
        9
        11
         import Html as H exposing (Html)

      ···
        142
        144
                     ( { model | isSubmittingForm = False }, Effect.signin credentials )

      
        143
        145
         

      
        144
        146
                 ApiSignInResponded (Err error) ->

      
        
        147
        +            -- TODO: check if error is Unauthorized and prompt use to activate account

      
        145
        148
                     ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none )

      
        146
        149
         

      
        147
        150
                 ApiSignUpResponded (Ok ()) ->

      ···
        225
        228
                     case ( now, lastClicked ) of

      
        226
        229
                         ( Just now_, Just last ) ->

      
        227
        230
                             let

      
        228
        
        -                        remainingMs =

      
        229
        
        -                            30 * 1000 - (Time.posixToMillis now_ - Time.posixToMillis last)

      
        
        231
        +                        elapsedMs =

      
        
        232
        +                            Time.posixToMillis now_ - Time.posixToMillis last

      
        230
        233
                             in

      
        231
        
        -                    if remainingMs > 0 then

      
        232
        
        -                        remainingMs // 1000

      
        233
        
        -

      
        234
        
        -                    else

      
        235
        
        -                        0

      
        
        234
        +                    max 0 ((30 * 1000 - elapsedMs) // 1000)

      
        236
        235
         

      
        237
        236
                         _ ->

      
        238
        237
                             0

      ···
        250
        249
                     , A.disabled (not canClick)

      
        251
        250
                     ]

      
        252
        251
                     [ H.text "Resend verification email" ]

      
        253
        
        -        , if canClick then

      
        254
        
        -            H.text ""

      
        255
        
        -

      
        256
        
        -          else

      
        257
        
        -            H.p [ A.class "text-gray-600 text-xs mt-2" ]

      
        258
        
        -                [ H.text

      
        259
        
        -                    ("You can request a new verification email in "

      
        260
        
        -                        ++ String.fromInt timeLeftSeconds

      
        261
        
        -                        ++ " seconds."

      
        262
        
        -                    )

      
        263
        
        -                ]

      
        
        252
        +        , Components.Utils.viewIf (not canClick)

      
        
        253
        +            (H.p

      
        
        254
        +                [ A.class "text-gray-600 text-xs mt-2" ]

      
        
        255
        +                [ H.text ("You can request a new verification email in " ++ String.fromInt timeLeftSeconds ++ " seconds.") ]

      
        
        256
        +            )

      
        264
        257
                 ]

      
        265
        258
         

      
        266
        259
         

      ···
        284
        277
         viewChangeVariant : Variant -> Html Msg

      
        285
        278
         viewChangeVariant variant =

      
        286
        279
             let

      
        287
        
        -        base =

      
        288
        
        -            "flex-1 px-4 py-2 rounded-md font-medium transition-colors"

      
        289
        
        -

      
        290
        
        -        buttonClasses : Bool -> String

      
        291
        280
                 buttonClasses active =

      
        
        281
        +            let

      
        
        282
        +                base =

      
        
        283
        +                    "flex-1 px-4 py-2 rounded-md font-medium transition-colors"

      
        
        284
        +            in

      
        292
        285
                     if active then

      
        293
        286
                         base ++ " bg-black text-white"

      
        294
        287
         

      ···
        336
        329
         

      
        337
        330
         viewFormInput : { field : Field, value : String } -> Html Msg

      
        338
        331
         viewFormInput opts =

      
        339
        
        -    H.div [ A.class "space-y-2" ]

      
        340
        
        -        [ H.label

      
        341
        
        -            [ A.class "block text-sm font-medium text-gray-700" ]

      
        342
        
        -            [ H.text (fromFieldToLabel opts.field) ]

      
        343
        
        -        , H.div []

      
        344
        
        -            [ H.input

      
        345
        
        -                [ 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"

      
        346
        
        -                , A.type_ (fromFieldToInputType opts.field)

      
        347
        
        -                , A.value opts.value

      
        348
        
        -                , A.placeholder (fromFieldToLabel opts.field)

      
        349
        
        -                , A.required True

      
        350
        
        -                , E.onInput (UserUpdatedInput opts.field)

      
        351
        
        -                ]

      
        352
        
        -                []

      
        353
        
        -            ]

      
        354
        
        -        ]

      
        
        332
        +    Components.Form.input

      
        
        333
        +        { id = fromFieldToInputType opts.field

      
        
        334
        +        , field = opts.field

      
        
        335
        +        , label = fromFieldToLabel opts.field

      
        
        336
        +        , type_ = fromFieldToInputType opts.field

      
        
        337
        +        , value = opts.value

      
        
        338
        +        , placeholder = fromFieldToLabel opts.field

      
        
        339
        +        , required = True

      
        
        340
        +        , onInput = UserUpdatedInput opts.field

      
        
        341
        +        , helpText = Nothing

      
        
        342
        +        , prefix = Nothing

      
        
        343
        +        }

      
        355
        344
         

      
        356
        345
         

      
        357
        346
         viewForgotPassword : Html Msg

      
M web/src/Pages/Home_.elm
···
        3
        3
         import Api

      
        4
        4
         import Api.Note

      
        5
        5
         import Components.Error

      
        
        6
        +import Components.Form

      
        
        7
        +import Components.Utils

      
        6
        8
         import Data.Note as Note

      
        7
        9
         import Effect exposing (Effect)

      
        8
        10
         import ExpirationOptions exposing (expirationOptions)

      ···
        209
        211
                         [ H.div [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ]

      
        210
        212
                             [ viewHeader model.pageVariant

      
        211
        213
                             , H.div [ A.class "p-6 space-y-6" ]

      
        212
        
        -                        [ case model.apiError of

      
        213
        
        -                            Just error ->

      
        214
        
        -                                Components.Error.error (Api.errorMessage error)

      
        215
        
        -

      
        216
        
        -                            Nothing ->

      
        217
        
        -                                H.text ""

      
        
        214
        +                        [ Components.Utils.viewMaybe model.apiError (\e -> Components.Error.error (Api.errorMessage e))

      
        218
        215
                                 , case model.pageVariant of

      
        219
        216
                                     CreateNote ->

      
        220
        217
                                         viewCreateNoteForm model shared.appURL

      ···
        257
        254
                 , A.class "space-y-6"

      
        258
        255
                 ]

      
        259
        256
                 [ viewTextarea

      
        260
        
        -        , viewFormInput

      
        261
        
        -            { field = Slug

      
        
        257
        +        , Components.Form.input

      
        
        258
        +            { id = "slug"

      
        
        259
        +            , field = Slug

      
        262
        260
                     , label = "Custom URL Slug (optional)"

      
        263
        261
                     , placeholder = "my-unique-slug"

      
        264
        262
                     , type_ = "text"

      
        265
        
        -            , help = "Leave empty to generate a random slug"

      
        
        263
        +            , helpText = Just "Leave empty to generate a random slug"

      
        266
        264
                     , prefix = Just (secretUrl appUrl "")

      
        
        265
        +            , onInput = UserUpdatedInput Slug

      
        
        266
        +            , required = False

      
        
        267
        +            , value = Maybe.withDefault "" model.slug

      
        267
        268
                     }

      
        268
        269
                 , H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ]

      
        269
        270
                     [ H.div [ A.class "space-y-6" ]

      
        270
        
        -                [ viewFormInput

      
        271
        
        -                    { field = Password

      
        
        271
        +                [ Components.Form.input

      
        
        272
        +                    { id = "password"

      
        
        273
        +                    , field = Password

      
        272
        274
                             , label = "Password Protection (optional)"

      
        273
        
        -                    , type_ = "text"

      
        
        275
        +                    , type_ = "password"

      
        274
        276
                             , placeholder = "Enter password to protect this paste"

      
        275
        
        -                    , help = "Viewers will need this password to access the paste"

      
        
        277
        +                    , helpText = Just "Viewers will need this password to access the paste"

      
        276
        278
                             , prefix = Nothing

      
        
        279
        +                    , onInput = UserUpdatedInput Password

      
        
        280
        +                    , required = False

      
        
        281
        +                    , value = Maybe.withDefault "" model.password

      
        277
        282
                             }

      
        278
        283
                         ]

      
        279
        284
                     , H.div [ A.class "space-y-6" ]

      ···
        302
        307
                     , 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"

      
        303
        308
                     ]

      
        304
        309
                     []

      
        305
        
        -        ]

      
        306
        
        -

      
        307
        
        -

      
        308
        
        -viewFormInput : { field : Field, label : String, placeholder : String, type_ : String, prefix : Maybe String, help : String } -> Html Msg

      
        309
        
        -viewFormInput options =

      
        310
        
        -    H.div [ A.class "space-y-2" ]

      
        311
        
        -        [ H.label

      
        312
        
        -            [ A.for (fromFieldToName options.field)

      
        313
        
        -            , A.class "block text-sm font-medium text-gray-700 mb-2"

      
        314
        
        -            ]

      
        315
        
        -            [ H.text options.label ]

      
        316
        
        -        , H.div [ A.class "flex items-center" ]

      
        317
        
        -            [ case options.prefix of

      
        318
        
        -                Just prefix ->

      
        319
        
        -                    H.span [ A.class "text-gray-500 text-md mr-2 whitespace-nowrap" ] [ H.text prefix ]

      
        320
        
        -

      
        321
        
        -                Nothing ->

      
        322
        
        -                    H.text ""

      
        323
        
        -            , H.input

      
        324
        
        -                [ E.onInput (UserUpdatedInput options.field)

      
        325
        
        -                , A.id (fromFieldToName options.field)

      
        326
        
        -                , A.type_ options.type_

      
        327
        
        -                , A.placeholder options.placeholder

      
        328
        
        -                , 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"

      
        329
        
        -                ]

      
        330
        
        -                []

      
        331
        
        -            ]

      
        332
        
        -        , H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text options.help ]

      
        333
        310
                 ]

      
        334
        311
         

      
        335
        312
         

      
M web/src/Pages/Secret/Slug_.elm
···
        3
        3
         import Api

      
        4
        4
         import Api.Note

      
        5
        5
         import Components.Error

      
        6
        
        -import Components.Note

      
        
        6
        +import Components.Utils

      
        7
        7
         import Data.Note exposing (Metadata, Note)

      
        8
        8
         import Effect exposing (Effect)

      
        9
        9
         import Html as H exposing (Html)

      ···
        199
        199
         viewShowNoteHeader : Zone -> String -> Note -> Html Msg

      
        200
        200
         viewShowNoteHeader zone slug note =

      
        201
        201
             H.div []

      
        202
        
        -        [ if note.burnBeforeExpiration then

      
        203
        
        -            H.div [ A.class "bg-orange-50 border-b border-orange-200 p-4" ]

      
        
        202
        +        [ Components.Utils.viewIf note.burnBeforeExpiration

      
        
        203
        +            (H.div [ A.class "bg-orange-50 border-b border-orange-200 p-4" ]

      
        204
        204
                         [ H.div [ A.class "flex items-center gap-3" ]

      
        205
        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 ]

      
        
        206
        +                        [ Components.Utils.loadSvg { path = "warning.svg", class = "w-4 h-4 text-orange-600" } ]

      
        207
        207
                             , H.p [ A.class "text-orange-800 text-sm font-medium" ]

      
        208
        208
                                 [ H.text "This note was destroyed. If you need to keep it, copy it before closing this window." ]

      
        209
        209
                             ]

      
        210
        210
                         ]

      
        211
        
        -

      
        212
        
        -          else

      
        213
        
        -            H.text ""

      
        
        211
        +            )

      
        214
        212
                 , H.div [ A.class "p-6 pb-4 border-b border-gray-200" ]

      
        215
        213
                     [ H.div [ A.class "flex justify-between items-start" ]

      
        216
        214
                         [ H.div []

      
        217
        215
                             [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text ("Note: " ++ slug) ]

      
        218
        216
                             , H.div [ A.class "text-sm text-gray-500 mt-2 space-y-1" ]

      
        219
        217
                                 [ 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 ""

      
        
        218
        +                        , Components.Utils.viewMaybe note.expiresAt (\n -> H.p [] [ H.text ("Expires at: " ++ T.toString zone n) ])

      
        226
        219
                                 ]

      
        227
        220
                             ]

      
        228
        221
                         , H.div [ A.class "flex gap-2" ]

      ···
        246
        239
             H.div [ A.class "p-6" ]

      
        247
        240
                 [ H.div [ A.class "text-center py-12" ]

      
        248
        241
                     [ 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 ]

      
        
        242
        +                [ Components.Utils.loadSvg { path = "note-not-found.svg", class = "w-8 h-8 text-red-500" } ]

      
        250
        243
                     , H.h2 [ A.class "text-xl font-semibold text-gray-900 mb-2" ]

      
        251
        244
                         [ H.text ("Note " ++ slug ++ " Not Found") ]

      
        252
        245
                     , H.div [ A.class "text-gray-600 mb-6 space-y-2" ]

      ···
        265
        258
                 ]

      
        266
        259
         

      
        267
        260
         

      
        268
        
        -viewOpenNote :

      
        269
        
        -    { slug : String

      
        270
        
        -    , hasPassword : Bool

      
        271
        
        -    , isLoading : Bool

      
        272
        
        -    , password : Maybe String

      
        273
        
        -    }

      
        274
        
        -    -> Html Msg

      
        
        261
        +viewOpenNote : { slug : String, hasPassword : Bool, isLoading : Bool, password : Maybe String } -> Html Msg

      
        275
        262
         viewOpenNote opts =

      
        276
        263
             let

      
        277
        264
                 isDisabled =

      ···
        295
        282
                 [ H.div [ A.class "text-center py-12" ]

      
        296
        283
                     [ H.div [ A.class "mb-6" ]

      
        297
        284
                         [ 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 ]

      
        
        285
        +                    [ Components.Utils.loadSvg { path = "note-icon.svg", class = "w-8 h-8 text-gray-400" } ]

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

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

      
        301
        288
                         ]

      ···
        303
        290
                         [ E.onSubmit UserClickedViewNote

      
        304
        291
                         , A.class "max-w-sm mx-auto space-y-4"

      
        305
        292
                         ]

      
        306
        
        -                [ if opts.hasPassword then

      
        307
        
        -                    H.div

      
        
        293
        +                [ Components.Utils.viewIf opts.hasPassword

      
        
        294
        +                    (H.div

      
        308
        295
                                 [ A.class "space-y-2" ]

      
        309
        296
                                 [ H.label

      
        310
        297
                                     [ A.class "block text-sm font-medium text-gray-700 text-left" ]

      ···
        315
        302
                                     ]

      
        316
        303
                                     []

      
        317
        304
                                 ]

      
        318
        
        -

      
        319
        
        -                  else

      
        320
        
        -                    H.text ""

      
        
        305
        +                    )

      
        321
        306
                         , H.button

      
        322
        307
                             [ A.class buttonData.class

      
        323
        308
                             , A.type_ "submit"

      
M web/src/Shared.elm
···
        1
        
        -module Shared exposing

      
        2
        
        -    ( Flags, decoder

      
        3
        
        -    , Model, Msg

      
        4
        
        -    , init, update, subscriptions

      
        5
        
        -    )

      
        6
        
        -

      
        7
        
        -{-|

      
        8
        
        -

      
        9
        
        -@docs Flags, decoder

      
        10
        
        -@docs Model, Msg

      
        11
        
        -@docs init, update, subscriptions

      
        12
        
        -

      
        13
        
        --}

      
        
        1
        +module Shared exposing (Flags, Model, Msg, decoder, init, subscriptions, update)

      
        14
        2
         

      
        15
        3
         import Api.Auth

      
        16
        4
         import Auth.User

      
        17
        
        -import Data.Credentials exposing (Credentials)

      
        18
        5
         import Dict

      
        19
        6
         import Effect exposing (Effect)

      
        20
        7
         import Json.Decode

      ···
        57
        44
         init : Result Json.Decode.Error Flags -> Route () -> ( Model, Effect Msg )

      
        58
        45
         init flagsResult _ =

      
        59
        46
             let

      
        60
        
        -        flags : Flags

      
        61
        47
                 flags =

      
        62
        48
                     flagsResult |> Result.withDefault { accessToken = Nothing, refreshToken = Nothing, appUrl = "" }

      
        63
        49
         

      
        64
        
        -        maybeCredentials : Maybe Credentials

      
        65
        50
                 maybeCredentials =

      
        66
        
        -            Maybe.map2

      
        67
        
        -                (\access refresh -> { accessToken = access, refreshToken = refresh })

      
        
        51
        +            Maybe.map2 (\access refresh -> { accessToken = access, refreshToken = refresh })

      
        68
        52
                         flags.accessToken

      
        69
        53
                         flags.refreshToken

      
        70
        54
         

      
        71
        
        -        user : Auth.User.SignInStatus

      
        72
        55
                 user =

      
        73
        56
                     case maybeCredentials of

      
        74
        57
                         Just credentials ->

      ···
        76
        59
         

      
        77
        60
                         Nothing ->

      
        78
        61
                             Auth.User.NotSignedIn

      
        79
        
        -

      
        80
        
        -        initModel : Model

      
        81
        
        -        initModel =

      
        82
        
        -            { user = user

      
        83
        
        -            , timeZone = Time.utc

      
        84
        
        -            , appURL = flags.appUrl

      
        85
        
        -            }

      
        86
        62
             in

      
        87
        
        -    ( initModel

      
        
        63
        +    ( { user = user

      
        
        64
        +      , timeZone = Time.utc

      
        
        65
        +      , appURL = flags.appUrl

      
        
        66
        +      }

      
        88
        67
             , Effect.batch

      
        89
        68
                 [ Time.now |> Task.perform Shared.Msg.CheckTokenExpiration |> Effect.sendCmd

      
        90
        69
                 , Time.here |> Task.perform Shared.Msg.GotZone |> Effect.sendCmd

      
M web/src/Shared/Msg.elm
···
        2
        2
         

      
        3
        3
         import Api

      
        4
        4
         import Data.Credentials exposing (Credentials)

      
        5
        
        -import Time

      
        
        5
        +import Time exposing (Posix, Zone)

      
        6
        6
         

      
        7
        7
         

      
        8
        8
         type Msg

      
        9
        
        -    = GotZone Time.Zone

      
        
        9
        +    = GotZone Zone

      
        10
        10
               -- Auth

      
        11
        11
             | Logout

      
        12
        12
             | SignedIn Credentials

      
        13
        13
               -- Session

      
        14
        
        -    | CheckTokenExpiration Time.Posix

      
        
        14
        +    | CheckTokenExpiration Posix

      
        15
        15
             | TriggerTokenRefresh

      
        16
        16
             | ApiRefreshTokensResponded (Result Api.Error Credentials)

      
A web/static/note-icon.svg
···
        
        1
        +<svg

      
        
        2
        +    xmlns="http://www.w3.org/2000/svg"

      
        
        3
        +    fill="none"

      
        
        4
        +    stroke="currentColor"

      
        
        5
        +    viewBox="0 0 24 24"

      
        
        6
        +>

      
        
        7
        +    <path

      
        
        8
        +        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"

      
        
        9
        +        stroke-width="2"

      
        
        10
        +        stroke-linecap="round"

      
        
        11
        +        stroke-linejoin="round"

      
        
        12
        +    />

      
        
        13
        +</svg>

      
A web/static/note-not-found.svg
···
        
        1
        +<svg

      
        
        2
        +    xmlns="http://www.w3.org/2000/svg"

      
        
        3
        +    fill="none"

      
        
        4
        +    stroke="currentColor"

      
        
        5
        +    viewBox="0 0 24 24"

      
        
        6
        +>

      
        
        7
        +    <path

      
        
        8
        +        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"

      
        
        9
        +        stroke-width="2"

      
        
        10
        +        stroke-linecap="round"

      
        
        11
        +        stroke-linejoin="round"

      
        
        12
        +    />

      
        
        13
        +    <path

      
        
        14
        +        d="M6 18L18 6M6 6l12 12"

      
        
        15
        +        stroke-width="2"

      
        
        16
        +        stroke-linecap="round"

      
        
        17
        +        stroke-linejoin="round"

      
        
        18
        +    />

      
        
        19
        +</svg>

      
A web/static/warning.svg
···
        
        1
        +<svg

      
        
        2
        +    xmlns="http://www.w3.org/2000/svg"

      
        
        3
        +    fill="none"

      
        
        4
        +    stroke="currentColor"

      
        
        5
        +    viewBox="0 0 24 24"

      
        
        6
        +>

      
        
        7
        +    <path

      
        
        8
        +        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"

      
        
        9
        +        stroke-width="2"

      
        
        10
        +        stroke-linecap="round"

      
        
        11
        +        stroke-linejoin="round"

      
        
        12
        +    />

      
        
        13
        +</svg>