all repos

onasty @ cd3e3ab

a one-time notes service
15 files changed, 573 insertions(+), 36 deletions(-)
web: create note page (#144)

* web: write html for create note

* web: api client for note creation

* web: create note

* web: add test for "create note response"

* web: init the types for variants of the page

* web: update the note creation api

* chore(env): update cors

* web: show user info about created note

* web: implement copy to clipboard

* web: make "copy link" button way too fancy

* web: pass frontend url into the elm app

* web: show user the actual frontend url to the secret

* web: make elm-review happy

* fixup! web: make elm-review happy

* fixup! web: show user the actual frontend url to the secret

* web: refactor the note creation form

* web: refactoring

* web: show the error message

* web: change the header content depended on page variant

* web: reuse error banner

* web: home, fix typography

* web: add password input for notes

* fixup! web: add password input for notes

* web: refactor

* web: add expiration time selector

* web: store static data in sep file

* web: update api

* web: change wording of checkbox label

* web: refactor model updates

* web: catch error when writing to clipboard
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-06-25 13:47:49 +0300
Parent: 67a69fb
M .env.example
···
        1
        
        -APP_ENV=debug

      
        2
        1
         APP_URL=http://localhost:8000

      
        
        2
        +FRONTEND_URL=http://localhost:1234

      
        
        3
        +

      
        
        4
        +APP_ENV=debug

      
        3
        5
         PASSWORD_SALT=onasty

      
        4
        6
         NOTE_PASSWORD_SALT=secret

      
        5
        7
         

      
        6
        8
         HTTP_PORT=8000

      
        7
        9
         

      
        8
        
        -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://onasty.localhost

      
        
        10
        +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://onasty.localhost,http://localhost:1234

      
        9
        11
         CORS_MAX_AGE=12h

      
        10
        12
         

      
        11
        13
         METRICS_ENABLED=true

      
M web/elm-land.json
···
        8
        8
                 "debugger": false

      
        9
        9
               }

      
        10
        10
             },

      
        11
        
        -    "env": [],

      
        
        11
        +    "env": ["FRONTEND_URL"],

      
        12
        12
             "html": {

      
        13
        13
               "attributes": {

      
        14
        14
                 "html": {

      
M web/elm.json
···
        14
        14
                     "elm/json": "1.1.3",

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

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

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

      
        17
        18
                     "simonh1000/elm-jwt": "7.1.1"

      
        18
        19
                 },

      
        19
        20
                 "indirect": {

      
        20
        21
                     "danfishgold/base64-bytes": "1.1.0",

      
        21
        22
                     "elm/bytes": "1.0.8",

      
        22
        23
                     "elm/file": "1.0.5",

      
        
        24
        +            "elm/regex": "1.0.0",

      
        23
        25
                     "elm/virtual-dom": "1.0.3"

      
        24
        26
                 }

      
        25
        27
             },

      
A web/src/Api/Note.elm
···
        
        1
        +module Api.Note exposing (create)

      
        
        2
        +

      
        
        3
        +import Api

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

      
        
        5
        +import Effect exposing (Effect)

      
        
        6
        +import Http

      
        
        7
        +import ISO8601

      
        
        8
        +import Json.Encode as E

      
        
        9
        +import Time exposing (Posix)

      
        
        10
        +

      
        
        11
        +

      
        
        12
        +create :

      
        
        13
        +    { onResponse : Result Api.Error CreateResponse -> msg

      
        
        14
        +    , content : String

      
        
        15
        +    , slug : Maybe String

      
        
        16
        +    , password : Maybe String

      
        
        17
        +    , expiresAt : Posix

      
        
        18
        +    , burnBeforeExpiration : Bool

      
        
        19
        +    }

      
        
        20
        +    -> Effect msg

      
        
        21
        +create options =

      
        
        22
        +    let

      
        
        23
        +        body : E.Value

      
        
        24
        +        body =

      
        
        25
        +            E.object

      
        
        26
        +                [ ( "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 )

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

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

      
        
        41
        +                    ( "expires_at", E.null )

      
        
        42
        +

      
        
        43
        +                  else

      
        
        44
        +                    ( "expires_at"

      
        
        45
        +                    , options.expiresAt

      
        
        46
        +                        |> ISO8601.fromPosix

      
        
        47
        +                        |> ISO8601.toString

      
        
        48
        +                        |> E.string

      
        
        49
        +                    )

      
        
        50
        +                ]

      
        
        51
        +    in

      
        
        52
        +    Effect.sendApiRequest

      
        
        53
        +        { endpoint = "/api/v1/note"

      
        
        54
        +        , method = "POST"

      
        
        55
        +        , body = Http.jsonBody body

      
        
        56
        +        , onResponse = options.onResponse

      
        
        57
        +        , decoder = Note.decodeCreateResponse

      
        
        58
        +        }

      
A web/src/Components/Error.elm
···
        
        1
        +module Components.Error exposing (error)

      
        
        2
        +

      
        
        3
        +import Html as H exposing (Html)

      
        
        4
        +import Html.Attributes as A

      
        
        5
        +

      
        
        6
        +

      
        
        7
        +error : String -> Html msg

      
        
        8
        +error errorMsg =

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

      
        
        10
        +        [ H.p [ A.class "text-red-800 text-sm" ] [ H.text errorMsg ] ]

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

      
        
        2
        +

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

      
        
        4
        +

      
        
        5
        +

      
        
        6
        +type alias CreateResponse =

      
        
        7
        +    { slug : String }

      
        
        8
        +

      
        
        9
        +

      
        
        10
        +decodeCreateResponse : Decoder CreateResponse

      
        
        11
        +decodeCreateResponse =

      
        
        12
        +    D.map CreateResponse

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

      
M web/src/Effect.elm
···
        6
        6
             , pushRoutePath, replaceRoutePath

      
        7
        7
             , loadExternalUrl, back

      
        8
        8
             , sendApiRequest, refreshTokens

      
        
        9
        +    , sendToClipboard

      
        9
        10
             , signin, logout, saveUser, clearUser

      
        10
        11
             , map, toCmd

      
        11
        12
             )

      ···
        22
        23
         @docs loadExternalUrl, back

      
        23
        24
         

      
        24
        25
         @docs sendApiRequest, refreshTokens

      
        
        26
        +@docs sendToClipboard

      
        25
        27
         @docs signin, logout, saveUser, clearUser

      
        26
        28
         

      
        27
        29
         @docs map, toCmd

      ···
        37
        39
         import Http

      
        38
        40
         import Json.Decode

      
        39
        41
         import Json.Encode

      
        40
        
        -import Ports exposing (sendToLocalStorage)

      
        
        42
        +import Ports

      
        41
        43
         import Route

      
        42
        44
         import Route.Path

      
        43
        45
         import Shared.Model

      ···
        59
        61
               -- SHARED

      
        60
        62
             | SendSharedMsg Shared.Msg.Msg

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

      
        
        64
        +    | SendToClipboard String

      
        62
        65
             | SendApiRequest (HttpRequestDetails msg)

      
        63
        66
         

      
        64
        67
         

      ···
        187
        190
                 }

      
        188
        191
         

      
        189
        192
         

      
        
        193
        +sendToClipboard : String -> Effect msg

      
        
        194
        +sendToClipboard text =

      
        
        195
        +    SendToClipboard text

      
        
        196
        +

      
        
        197
        +

      
        190
        198
         refreshTokens : Effect msg

      
        191
        199
         refreshTokens =

      
        192
        200
             SendSharedMsg Shared.Msg.TriggerTokenRefresh

      ···
        255
        263
                 SendToLocalStorage options ->

      
        256
        264
                     SendToLocalStorage options

      
        257
        265
         

      
        
        266
        +        SendToClipboard text ->

      
        
        267
        +            SendToClipboard text

      
        
        268
        +

      
        258
        269
                 SendApiRequest opts ->

      
        259
        270
                     SendApiRequest

      
        260
        271
                         { endpoint = opts.endpoint

      ···
        305
        316
                         |> Task.perform options.fromSharedMsg

      
        306
        317
         

      
        307
        318
                 SendToLocalStorage opts ->

      
        308
        
        -            sendToLocalStorage opts

      
        
        319
        +            Ports.sendToLocalStorage opts

      
        
        320
        +

      
        
        321
        +        SendToClipboard text ->

      
        
        322
        +            Ports.sendToClipboard text

      
        309
        323
         

      
        310
        324
                 SendApiRequest opts ->

      
        311
        325
                     let

      
A web/src/ExpirationOptions.elm
···
        
        1
        +module ExpirationOptions exposing (ExpiresAt, expirationOptions)

      
        
        2
        +

      
        
        3
        +

      
        
        4
        +type alias ExpiresAt =

      
        
        5
        +    { text : String, value : Int }

      
        
        6
        +

      
        
        7
        +

      
        
        8
        +expirationOptions : List ExpiresAt

      
        
        9
        +expirationOptions =

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

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

      
        
        12
        +    , { text = "12 hours", value = 12 * 60 * 60 * 1000 }

      
        
        13
        +    , { text = "1 day", value = 24 * 60 * 60 * 1000 }

      
        
        14
        +    , { text = "3 days", value = 3 * 24 * 60 * 60 * 1000 }

      
        
        15
        +    , { text = "7 days", value = 7 * 24 * 60 * 60 * 1000 }

      
        
        16
        +    ]

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

      
        4
        4
         import Api.Auth

      
        5
        5
         import Auth.User

      
        
        6
        +import Components.Error

      
        6
        7
         import Data.Credentials exposing (Credentials)

      
        7
        8
         import Effect exposing (Effect)

      
        8
        9
         import Html as H exposing (Html)

      ···
        197
        198
         viewBanner model =

      
        198
        199
             case ( model.apiError, model.gotSignedUp ) of

      
        199
        200
                 ( Just error, False ) ->

      
        200
        
        -            viewBannerError error

      
        
        201
        +            Components.Error.error (Api.errorMessage error)

      
        201
        202
         

      
        202
        203
                 ( Nothing, True ) ->

      
        203
        204
                     viewBannerSuccess model.now model.lastClicked

      ···
        263
        264
                                 ++ " seconds."

      
        264
        265
                             )

      
        265
        266
                         ]

      
        266
        
        -        ]

      
        267
        
        -

      
        268
        
        -

      
        269
        
        -viewBannerError : Api.Error -> Html Msg

      
        270
        
        -viewBannerError error =

      
        271
        
        -    H.div [ A.class "bg-red-50 border border-red-200 rounded-md p-4 mb-3" ]

      
        272
        
        -        [ H.p

      
        273
        
        -            [ A.class "text-red-800 text-sm" ]

      
        274
        
        -            [ H.text (Api.errorMessage error) ]

      
        275
        267
                 ]

      
        276
        268
         

      
        277
        269
         

      ···
        358
        350
                         , A.type_ (fromFieldToInputType opts.field)

      
        359
        351
                         , A.value opts.value

      
        360
        352
                         , A.placeholder (fromFieldToLabel opts.field)

      
        
        353
        +                , A.required True

      
        361
        354
                         , E.onInput (UserUpdatedInput opts.field)

      
        362
        355
                         ]

      
        363
        356
                         []

      
M web/src/Pages/Home_.elm
···
        1
        
        -module Pages.Home_ exposing (Model, Msg, page)

      
        
        1
        +module Pages.Home_ exposing (Model, Msg, PageVariant, page)

      
        2
        2
         

      
        
        3
        +import Api

      
        
        4
        +import Api.Note

      
        
        5
        +import Components.Error

      
        
        6
        +import Data.Note as Note

      
        3
        7
         import Effect exposing (Effect)

      
        4
        
        -import Html as H

      
        
        8
        +import ExpirationOptions exposing (expirationOptions)

      
        
        9
        +import Html as H exposing (Html)

      
        5
        10
         import Html.Attributes as A

      
        6
        11
         import Html.Events as E

      
        7
        12
         import Layouts

      
        8
        13
         import Page exposing (Page)

      
        
        14
        +import Process

      
        9
        15
         import Route exposing (Route)

      
        10
        16
         import Shared

      
        
        17
        +import Task

      
        
        18
        +import Time exposing (Posix)

      
        11
        19
         import View exposing (View)

      
        12
        20
         

      
        13
        21
         

      ···
        15
        23
         page shared _ =

      
        16
        24
             Page.new

      
        17
        25
                 { init = init shared

      
        18
        
        -        , update = update

      
        
        26
        +        , update = update shared

      
        19
        27
                 , subscriptions = subscriptions

      
        20
        28
                 , view = view shared

      
        21
        29
                 }

      
        22
        
        -        |> Page.withLayout Layouts.Header

      
        
        30
        +        |> Page.withLayout (\_ -> Layouts.Header {})

      
        23
        31
         

      
        24
        32
         

      
        25
        33
         

      ···
        27
        35
         

      
        28
        36
         

      
        29
        37
         type alias Model =

      
        30
        
        -    {}

      
        
        38
        +    { pageVariant : PageVariant

      
        
        39
        +    , content : String

      
        
        40
        +    , slug : Maybe String

      
        
        41
        +    , password : Maybe String

      
        
        42
        +    , expirationTime : Maybe Int

      
        
        43
        +    , dontBurnBeforeExpiration : Bool

      
        
        44
        +    , apiError : Maybe Api.Error

      
        
        45
        +    , userClickedCopyLink : Bool

      
        
        46
        +    , now : Maybe Posix

      
        
        47
        +    }

      
        
        48
        +

      
        
        49
        +

      
        
        50
        +

      
        
        51
        +-- TODO: store slug as Slug type

      
        
        52
        +

      
        
        53
        +

      
        
        54
        +type PageVariant

      
        
        55
        +    = CreateNote

      
        
        56
        +    | NoteCreated String

      
        31
        57
         

      
        32
        58
         

      
        33
        59
         init : Shared.Model -> () -> ( Model, Effect Msg )

      
        34
        60
         init _ () =

      
        35
        
        -    ( {}, Effect.none )

      
        
        61
        +    ( { pageVariant = CreateNote

      
        
        62
        +      , content = ""

      
        
        63
        +      , slug = Nothing

      
        
        64
        +      , password = Nothing

      
        
        65
        +      , expirationTime = Nothing

      
        
        66
        +      , dontBurnBeforeExpiration = True

      
        
        67
        +      , userClickedCopyLink = False

      
        
        68
        +      , apiError = Nothing

      
        
        69
        +      , now = Nothing

      
        
        70
        +      }

      
        
        71
        +    , Effect.none

      
        
        72
        +    )

      
        36
        73
         

      
        37
        74
         

      
        38
        75
         

      ···
        40
        77
         

      
        41
        78
         

      
        42
        79
         type Msg

      
        43
        
        -    = NoOp

      
        
        80
        +    = CopyButtonReset

      
        
        81
        +    | Tick Posix

      
        
        82
        +    | UserUpdatedInput Field String

      
        
        83
        +    | UserClickedCheckbox Bool

      
        
        84
        +    | UserClickedSubmit

      
        
        85
        +    | UserClickedCreateNewNote

      
        
        86
        +    | UserClickedCopyLink

      
        
        87
        +    | ApiCreateNoteResponded (Result Api.Error Note.CreateResponse)

      
        44
        88
         

      
        45
        89
         

      
        46
        
        -update : Msg -> Model -> ( Model, Effect Msg )

      
        47
        
        -update msg model =

      
        
        90
        +type Field

      
        
        91
        +    = Content

      
        
        92
        +    | Slug

      
        
        93
        +    | Password

      
        
        94
        +    | ExpirationTime

      
        
        95
        +

      
        
        96
        +

      
        
        97
        +update : Shared.Model -> Msg -> Model -> ( Model, Effect Msg )

      
        
        98
        +update shared msg model =

      
        48
        99
             case msg of

      
        49
        
        -        NoOp ->

      
        50
        
        -            ( model, Effect.none )

      
        
        100
        +        Tick now ->

      
        
        101
        +            ( { model | now = Just now }, Effect.none )

      
        
        102
        +

      
        
        103
        +        CopyButtonReset ->

      
        
        104
        +            ( { model | userClickedCopyLink = False }, Effect.none )

      
        
        105
        +

      
        
        106
        +        UserClickedSubmit ->

      
        
        107
        +            let

      
        
        108
        +                expiresAt : Posix

      
        
        109
        +                expiresAt =

      
        
        110
        +                    case ( model.now, model.expirationTime ) of

      
        
        111
        +                        ( Just now, Just expirationTime ) ->

      
        
        112
        +                            Time.millisToPosix (Time.posixToMillis now + expirationTime)

      
        
        113
        +

      
        
        114
        +                        _ ->

      
        
        115
        +                            Time.millisToPosix 0

      
        
        116
        +            in

      
        
        117
        +            ( model

      
        
        118
        +            , Api.Note.create

      
        
        119
        +                { onResponse = ApiCreateNoteResponded

      
        
        120
        +                , content = model.content

      
        
        121
        +                , slug = model.slug

      
        
        122
        +                , password = model.password

      
        
        123
        +                , burnBeforeExpiration = not model.dontBurnBeforeExpiration

      
        
        124
        +                , expiresAt = expiresAt

      
        
        125
        +                }

      
        
        126
        +            )

      
        
        127
        +

      
        
        128
        +        UserClickedCreateNewNote ->

      
        
        129
        +            ( { model

      
        
        130
        +                | pageVariant = CreateNote

      
        
        131
        +                , content = ""

      
        
        132
        +                , slug = Nothing

      
        
        133
        +                , password = Nothing

      
        
        134
        +                , apiError = Nothing

      
        
        135
        +              }

      
        
        136
        +            , Effect.none

      
        
        137
        +            )

      
        
        138
        +

      
        
        139
        +        UserClickedCopyLink ->

      
        
        140
        +            ( { model | userClickedCopyLink = True }

      
        
        141
        +            , Effect.batch

      
        
        142
        +                [ Effect.sendCmd (Task.perform (\_ -> CopyButtonReset) (Process.sleep 2000))

      
        
        143
        +                , Effect.sendToClipboard (secretUrl shared.appURL (Maybe.withDefault "" model.slug))

      
        
        144
        +                ]

      
        
        145
        +            )

      
        
        146
        +

      
        
        147
        +        UserUpdatedInput Content content ->

      
        
        148
        +            ( { model | content = content }, Effect.none )

      
        
        149
        +

      
        
        150
        +        UserUpdatedInput Slug slug ->

      
        
        151
        +            if slug == "" then

      
        
        152
        +                ( { model | slug = Nothing }, Effect.none )

      
        
        153
        +

      
        
        154
        +            else

      
        
        155
        +                ( { model | slug = Just slug }, Effect.none )

      
        
        156
        +

      
        
        157
        +        UserUpdatedInput Password password ->

      
        
        158
        +            if password == "" then

      
        
        159
        +                ( { model | password = Nothing }, Effect.none )

      
        
        160
        +

      
        
        161
        +            else

      
        
        162
        +                ( { model | password = Just password }, Effect.none )

      
        
        163
        +

      
        
        164
        +        UserUpdatedInput ExpirationTime expirationTime ->

      
        
        165
        +            if expirationTime == "0" then

      
        
        166
        +                ( { model | expirationTime = Nothing }, Effect.none )

      
        
        167
        +

      
        
        168
        +            else

      
        
        169
        +                ( { model | expirationTime = String.toInt expirationTime }, Effect.none )

      
        
        170
        +

      
        
        171
        +        UserClickedCheckbox burnBeforeExpiration ->

      
        
        172
        +            ( { model | dontBurnBeforeExpiration = burnBeforeExpiration }, Effect.none )

      
        
        173
        +

      
        
        174
        +        ApiCreateNoteResponded (Ok response) ->

      
        
        175
        +            ( { model | pageVariant = NoteCreated response.slug, slug = Just response.slug, apiError = Nothing }, Effect.none )

      
        
        176
        +

      
        
        177
        +        ApiCreateNoteResponded (Err error) ->

      
        
        178
        +            ( { model | apiError = Just error }, Effect.none )

      
        51
        179
         

      
        52
        180
         

      
        53
        181
         

      ···
        55
        183
         

      
        56
        184
         

      
        57
        185
         subscriptions : Model -> Sub Msg

      
        58
        
        -subscriptions _ =

      
        59
        
        -    Sub.none

      
        
        186
        +subscriptions model =

      
        
        187
        +    case model.expirationTime of

      
        
        188
        +        Just _ ->

      
        
        189
        +            Time.every 1000 Tick

      
        
        190
        +

      
        
        191
        +        _ ->

      
        
        192
        +            Sub.none

      
        60
        193
         

      
        61
        194
         

      
        62
        195
         

      
        63
        196
         -- VIEW

      
        64
        197
         

      
        65
        198
         

      
        
        199
        +secretUrl : String -> String -> String

      
        
        200
        +secretUrl appUrl slug =

      
        
        201
        +    appUrl ++ "/secret/" ++ slug

      
        
        202
        +

      
        
        203
        +

      
        66
        204
         view : Shared.Model -> Model -> View Msg

      
        67
        
        -view _ _ =

      
        68
        
        -    { title = "Homepage"

      
        
        205
        +view shared model =

      
        
        206
        +    { title = "Onasty"

      
        69
        207
             , body =

      
        70
        
        -        [ H.div [ A.class "w-full max-w-6xl mx-auto" ]

      
        71
        
        -            [ H.p [ E.onClick NoOp ] [ H.text "Hello, world!" ] ]

      
        
        208
        +        [ H.div [ A.class "py-8 px-4 " ]

      
        
        209
        +            [ H.div [ A.class "w-full max-w-4xl mx-auto" ]

      
        
        210
        +                [ H.div [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ]

      
        
        211
        +                    [ viewHeader model.pageVariant

      
        
        212
        +                    , H.div [ A.class "p-6 space-y-6" ]

      
        
        213
        +                        [ case model.apiError of

      
        
        214
        +                            Just error ->

      
        
        215
        +                                Components.Error.error (Api.errorMessage error)

      
        
        216
        +

      
        
        217
        +                            Nothing ->

      
        
        218
        +                                H.text ""

      
        
        219
        +                        , case model.pageVariant of

      
        
        220
        +                            CreateNote ->

      
        
        221
        +                                viewCreateNoteForm model shared.appURL

      
        
        222
        +

      
        
        223
        +                            NoteCreated slug ->

      
        
        224
        +                                viewNoteCreated model.userClickedCopyLink shared.appURL slug

      
        
        225
        +                        ]

      
        
        226
        +                    ]

      
        
        227
        +                ]

      
        
        228
        +            ]

      
        72
        229
                 ]

      
        73
        230
             }

      
        
        231
        +

      
        
        232
        +

      
        
        233
        +viewHeader : PageVariant -> Html Msg

      
        
        234
        +viewHeader pageVariant =

      
        
        235
        +    H.div [ A.class "p-6 pb-4 border-b border-gray-200" ]

      
        
        236
        +        [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ]

      
        
        237
        +            [ H.text

      
        
        238
        +                (case pageVariant of

      
        
        239
        +                    CreateNote ->

      
        
        240
        +                        "Create a new note"

      
        
        241
        +

      
        
        242
        +                    NoteCreated _ ->

      
        
        243
        +                        "Paste Created Successfully!"

      
        
        244
        +                )

      
        
        245
        +            ]

      
        
        246
        +        ]

      
        
        247
        +

      
        
        248
        +

      
        
        249
        +

      
        
        250
        +-- VIEW CREATE NOTE

      
        
        251
        +-- TODO: validate form

      
        
        252
        +

      
        
        253
        +

      
        
        254
        +viewCreateNoteForm : Model -> String -> Html Msg

      
        
        255
        +viewCreateNoteForm model appUrl =

      
        
        256
        +    H.form

      
        
        257
        +        [ E.onSubmit UserClickedSubmit

      
        
        258
        +        , A.class "space-y-6"

      
        
        259
        +        ]

      
        
        260
        +        [ viewTextarea

      
        
        261
        +        , viewFormInput

      
        
        262
        +            { field = Slug

      
        
        263
        +            , label = "Custom URL Slug (optional)"

      
        
        264
        +            , placeholder = "my-unique-slug"

      
        
        265
        +            , type_ = "text"

      
        
        266
        +            , help = "Leave empty to generate a random slug"

      
        
        267
        +            , prefix = Just (secretUrl appUrl "")

      
        
        268
        +            }

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

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

      
        
        271
        +                [ viewFormInput

      
        
        272
        +                    { field = Password

      
        
        273
        +                    , label = "Password Protection (optional)"

      
        
        274
        +                    , type_ = "text"

      
        
        275
        +                    , placeholder = "Enter password to protect this paste"

      
        
        276
        +                    , help = "Viewers will need this password to access the paste"

      
        
        277
        +                    , prefix = Nothing

      
        
        278
        +                    }

      
        
        279
        +                ]

      
        
        280
        +            , H.div [ A.class "space-y-6" ]

      
        
        281
        +                [ viewExpirationTimeSelector

      
        
        282
        +                , viewBurnBeforeExpirationCheckbox

      
        
        283
        +                ]

      
        
        284
        +            ]

      
        
        285
        +        , H.div [ A.class "flex justify-end" ] [ viewSubmitButton model ]

      
        
        286
        +        ]

      
        
        287
        +

      
        
        288
        +

      
        
        289
        +viewTextarea : Html Msg

      
        
        290
        +viewTextarea =

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

      
        
        292
        +        [ H.label

      
        
        293
        +            [ A.for (fromFieldToName Content)

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

      
        
        295
        +            ]

      
        
        296
        +            [ H.text "Content" ]

      
        
        297
        +        , H.textarea

      
        
        298
        +            [ E.onInput (UserUpdatedInput Content)

      
        
        299
        +            , A.id (fromFieldToName Content)

      
        
        300
        +            , A.placeholder "Write your note here..."

      
        
        301
        +            , A.required True

      
        
        302
        +            , A.rows 20

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

      
        
        304
        +            ]

      
        
        305
        +            []

      
        
        306
        +        ]

      
        
        307
        +

      
        
        308
        +

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

      
        
        310
        +viewFormInput options =

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

      
        
        312
        +        [ H.label

      
        
        313
        +            [ A.for (fromFieldToName options.field)

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

      
        
        315
        +            ]

      
        
        316
        +            [ H.text options.label ]

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

      
        
        318
        +            [ case options.prefix of

      
        
        319
        +                Just prefix ->

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

      
        
        321
        +

      
        
        322
        +                Nothing ->

      
        
        323
        +                    H.text ""

      
        
        324
        +            , H.input

      
        
        325
        +                [ E.onInput (UserUpdatedInput options.field)

      
        
        326
        +                , A.id (fromFieldToName options.field)

      
        
        327
        +                , A.type_ options.type_

      
        
        328
        +                , A.placeholder options.placeholder

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

      
        
        330
        +                ]

      
        
        331
        +                []

      
        
        332
        +            ]

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

      
        
        334
        +        ]

      
        
        335
        +

      
        
        336
        +

      
        
        337
        +viewExpirationTimeSelector : Html Msg

      
        
        338
        +viewExpirationTimeSelector =

      
        
        339
        +    H.div []

      
        
        340
        +        [ H.label [ A.for (fromFieldToName ExpirationTime), A.class "block text-sm font-medium text-gray-700 mb-2" ] [ H.text "Expiration Time (optional)" ]

      
        
        341
        +        , H.select

      
        
        342
        +            [ A.id (fromFieldToName ExpirationTime)

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

      
        
        344
        +            , E.onInput (UserUpdatedInput ExpirationTime)

      
        
        345
        +            ]

      
        
        346
        +            (List.map

      
        
        347
        +                (\e ->

      
        
        348
        +                    H.option

      
        
        349
        +                        [ A.value (String.fromInt e.value) ]

      
        
        350
        +                        [ H.text e.text ]

      
        
        351
        +                )

      
        
        352
        +                expirationOptions

      
        
        353
        +            )

      
        
        354
        +        ]

      
        
        355
        +

      
        
        356
        +

      
        
        357
        +viewBurnBeforeExpirationCheckbox : Html Msg

      
        
        358
        +viewBurnBeforeExpirationCheckbox =

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

      
        
        360
        +        [ H.div [ A.class "flex items-start space-x-3" ]

      
        
        361
        +            [ H.input

      
        
        362
        +                [ E.onCheck UserClickedCheckbox

      
        
        363
        +                , A.id "burn"

      
        
        364
        +                , A.type_ "checkbox"

      
        
        365
        +                , A.class "mt-1 h-4 w-4 text-black border-gray-300 rounded focus:ring-black focus:ring-2"

      
        
        366
        +                ]

      
        
        367
        +                []

      
        
        368
        +            , H.div [ A.class "flex-1" ]

      
        
        369
        +                [ H.label [ A.for "burn", A.class "block text-sm font-medium text-gray-700 cursor-pointer" ]

      
        
        370
        +                    [ H.text "Don't delete note until expiration time, even if it has been read it" ]

      
        
        371
        +                ]

      
        
        372
        +            ]

      
        
        373
        +        ]

      
        
        374
        +

      
        
        375
        +

      
        
        376
        +viewSubmitButton : Model -> Html Msg

      
        
        377
        +viewSubmitButton model =

      
        
        378
        +    H.button

      
        
        379
        +        [ A.type_ "submit"

      
        
        380
        +        , A.disabled (isFormDisabled model)

      
        
        381
        +        , A.class

      
        
        382
        +            (if isFormDisabled model then

      
        
        383
        +                "px-6 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors"

      
        
        384
        +

      
        
        385
        +             else

      
        
        386
        +                "px-6 py-2 bg-black text-white rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors"

      
        
        387
        +            )

      
        
        388
        +        ]

      
        
        389
        +        [ H.text "Create note" ]

      
        
        390
        +

      
        
        391
        +

      
        
        392
        +isFormDisabled : Model -> Bool

      
        
        393
        +isFormDisabled model =

      
        
        394
        +    String.isEmpty model.content

      
        
        395
        +

      
        
        396
        +

      
        
        397
        +viewCreateNewNoteButton : Html Msg

      
        
        398
        +viewCreateNewNoteButton =

      
        
        399
        +    H.button

      
        
        400
        +        [ A.class "px-6 py-2 bg-black text-white rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors"

      
        
        401
        +        , E.onClick UserClickedCreateNewNote

      
        
        402
        +        ]

      
        
        403
        +        [ H.text "Create New Paste" ]

      
        
        404
        +

      
        
        405
        +

      
        
        406
        +fromFieldToName : Field -> String

      
        
        407
        +fromFieldToName field =

      
        
        408
        +    case field of

      
        
        409
        +        Content ->

      
        
        410
        +            "content"

      
        
        411
        +

      
        
        412
        +        Slug ->

      
        
        413
        +            "slug"

      
        
        414
        +

      
        
        415
        +        Password ->

      
        
        416
        +            "password"

      
        
        417
        +

      
        
        418
        +        ExpirationTime ->

      
        
        419
        +            "expiration"

      
        
        420
        +

      
        
        421
        +

      
        
        422
        +

      
        
        423
        +-- VIEW NOTE CREATED

      
        
        424
        +

      
        
        425
        +

      
        
        426
        +viewNoteCreated : Bool -> String -> String -> Html Msg

      
        
        427
        +viewNoteCreated userClickedCopyLink appUrl slug =

      
        
        428
        +    H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-6" ]

      
        
        429
        +        [ H.div [ A.class "bg-white border border-green-300 rounded-md p-4 mb-4" ]

      
        
        430
        +            [ H.p [ A.class "text-sm text-gray-600 mb-2" ]

      
        
        431
        +                [ H.text "Your paste is available at:" ]

      
        
        432
        +            , H.p [ A.class "font-mono text-sm text-gray-800 break-all" ]

      
        
        433
        +                [ H.text (secretUrl appUrl slug) ]

      
        
        434
        +            ]

      
        
        435
        +        , H.div [ A.class "flex gap-3" ]

      
        
        436
        +            [ viewCopyLinkButton userClickedCopyLink

      
        
        437
        +            , viewCreateNewNoteButton

      
        
        438
        +            ]

      
        
        439
        +        ]

      
        
        440
        +

      
        
        441
        +

      
        
        442
        +viewCopyLinkButton : Bool -> Html Msg

      
        
        443
        +viewCopyLinkButton isClicked =

      
        
        444
        +    let

      
        
        445
        +        base : String

      
        
        446
        +        base =

      
        
        447
        +            "px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors"

      
        
        448
        +    in

      
        
        449
        +    H.button

      
        
        450
        +        [ A.class

      
        
        451
        +            (if isClicked then

      
        
        452
        +                base ++ " bg-green-100 border-green-300 text-green-700"

      
        
        453
        +

      
        
        454
        +             else

      
        
        455
        +                base ++ " border-gray-300 text-gray-700 hover:bg-gray-50"

      
        
        456
        +            )

      
        
        457
        +        , E.onClick UserClickedCopyLink

      
        
        458
        +        ]

      
        
        459
        +        [ H.text

      
        
        460
        +            (if isClicked then

      
        
        461
        +                "Copied!"

      
        
        462
        +

      
        
        463
        +             else

      
        
        464
        +                "Copy URL"

      
        
        465
        +            )

      
        
        466
        +        ]

      
M web/src/Ports.elm
···
        1
        
        -port module Ports exposing (sendToLocalStorage)

      
        
        1
        +port module Ports exposing (sendToClipboard, sendToLocalStorage)

      
        2
        2
         

      
        3
        3
         import Json.Encode

      
        4
        4
         

      
        5
        5
         

      
        6
        6
         port sendToLocalStorage : { key : String, value : Json.Encode.Value } -> Cmd msg

      
        
        7
        +

      
        
        8
        +

      
        
        9
        +port sendToClipboard : String -> Cmd msg

      
M web/src/Shared.elm
···
        34
        34
         type alias Flags =

      
        35
        35
             { accessToken : Maybe String

      
        36
        36
             , refreshToken : Maybe String

      
        
        37
        +    , appUrl : String

      
        37
        38
             }

      
        38
        39
         

      
        39
        40
         

      
        40
        41
         decoder : Json.Decode.Decoder Flags

      
        41
        42
         decoder =

      
        42
        
        -    Json.Decode.map2 Flags

      
        
        43
        +    Json.Decode.map3 Flags

      
        43
        44
                 (Json.Decode.field "access_token" (Json.Decode.maybe Json.Decode.string))

      
        44
        45
                 (Json.Decode.field "refresh_token" (Json.Decode.maybe Json.Decode.string))

      
        
        46
        +        (Json.Decode.field "app_url" Json.Decode.string)

      
        45
        47
         

      
        46
        48
         

      
        47
        49
         

      ···
        57
        59
             let

      
        58
        60
                 flags : Flags

      
        59
        61
                 flags =

      
        60
        
        -            flagsResult |> Result.withDefault { accessToken = Nothing, refreshToken = Nothing }

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

      
        61
        63
         

      
        62
        64
                 maybeCredentials : Maybe Credentials

      
        63
        65
                 maybeCredentials =

      ···
        79
        81
                 initModel =

      
        80
        82
                     { user = user

      
        81
        83
                     , timeZone = Time.utc

      
        
        84
        +            , appURL = flags.appUrl

      
        82
        85
                     }

      
        83
        86
             in

      
        84
        87
             ( initModel

      
M web/src/Shared/Model.elm
···
        7
        7
         type alias Model =

      
        8
        8
             { user : Auth.User.SignInStatus

      
        9
        9
             , timeZone : Time.Zone

      
        
        10
        +    , appURL : String

      
        10
        11
             }

      
M web/src/interop.js
···
        1
        1
         import "./styles.css";

      
        2
        2
         

      
        3
        
        -export const flags = (_) => {

      
        
        3
        +export const flags = ({ env }) => {

      
        4
        4
           return {

      
        5
        5
             access_token: JSON.parse(window.localStorage.access_token || "null"),

      
        6
        6
             refresh_token: JSON.parse(window.localStorage.refresh_token || "null"),

      
        
        7
        +    app_url: env.FRONTEND_URL || "http://localhost:3000",

      
        7
        8
           };

      
        8
        9
         };

      
        9
        10
         

      ···
        11
        12
           if (app.ports?.sendToLocalStorage) {

      
        12
        13
             app.ports.sendToLocalStorage.subscribe(({ key, value }) => {

      
        13
        14
               window.localStorage[key] = JSON.stringify(value);

      
        
        15
        +    });

      
        
        16
        +  }

      
        
        17
        +

      
        
        18
        +  if (app.ports?.sendToClipboard) {

      
        
        19
        +    app.ports.sendToClipboard.subscribe(async (text) => {

      
        
        20
        +      try {

      
        
        21
        +        await navigator.clipboard.writeText(text);

      
        
        22
        +      } catch (error) {

      
        
        23
        +        console.error("Failed to write to clipboard:", error);

      
        
        24
        +      }

      
        14
        25
             });

      
        15
        26
           }

      
        16
        27
         };

      
A web/tests/UnitTests/Data/Note.elm
···
        
        1
        +module UnitTests.Data.Note exposing (suite)

      
        
        2
        +

      
        
        3
        +import Data.Note

      
        
        4
        +import Expect

      
        
        5
        +import Json.Decode as D

      
        
        6
        +import Test exposing (Test, describe, test)

      
        
        7
        +

      
        
        8
        +

      
        
        9
        +suite : Test

      
        
        10
        +suite =

      
        
        11
        +    describe "Data.Note"

      
        
        12
        +        [ test "decodeCreateResponse"

      
        
        13
        +            (\_ ->

      
        
        14
        +                "{\"slug\":\"the.note-slug\"}"

      
        
        15
        +                    |> D.decodeString Data.Note.decodeCreateResponse

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

      
        
        17
        +            )

      
        
        18
        +        ]