all repos

onasty @ ffa032688c546167290f5148938aa07d78f1cdd6

a one-time notes service
9 files changed, 334 insertions(+), 8 deletions(-)
feat(web): add dashboard (#214)

* fix: this was forgotten to remove while debugging

* feat: add dashboard page

* feat: delete note

* web: add "has password" note badge

* web: remove deleted note from the list

* web: set the page title

* web: show error
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-09-22 16:15:40 +0300
Parent: cbf53ca
M api/components/schemas/Note.yml
···
        1
        1
         type: object

      
        2
        2
         required:

      
        
        3
        +  - slug

      
        3
        4
           - content

      
        4
        5
           - read_at

      
        5
        6
           - keep_before_expiration

      
        6
        7
           - created_at

      
        7
        8
           - expires_at

      
        8
        9
         properties:

      
        
        10
        +  slug:

      
        
        11
        +    type: string

      
        
        12
        +    example: f87abf56-3f01-4709-bf54-7aa0e1a6407b

      
        9
        13
           content:

      
        10
        14
             type: string

      
        11
        15
             example: note content

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

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

      
        2
        2
         

      
        3
        3
         import Api

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

      
        5
        5
         import Effect exposing (Effect)

      
        6
        6
         import Http

      
        7
        7
         import Iso8601

      
        
        8
        +import Json.Decode as D

      
        8
        9
         import Json.Encode as E

      
        9
        10
         import Time exposing (Posix)

      
        10
        11
         

      ···
        78
        79
                         }

      
        79
        80
         

      
        80
        81
         

      
        
        82
        +delete : { onResponse : Result Api.Error () -> msg, slug : String } -> Effect msg

      
        
        83
        +delete options =

      
        
        84
        +    Effect.sendApiRequest

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

      
        
        86
        +        , method = "DELETE"

      
        
        87
        +        , body = Http.emptyBody

      
        
        88
        +        , onResponse = options.onResponse

      
        
        89
        +        , decoder = D.succeed ()

      
        
        90
        +        }

      
        
        91
        +

      
        
        92
        +

      
        81
        93
         getMetadata :

      
        82
        94
             { onResponse : Result Api.Error Metadata -> msg

      
        83
        95
             , slug : String

      ···
        91
        103
                 , onResponse = options.onResponse

      
        92
        104
                 , decoder = Note.decodeMetadata

      
        93
        105
                 }

      
        
        106
        +

      
        
        107
        +

      
        
        108
        +getAll : { onResponse : Result Api.Error (List Note) -> msg } -> Effect msg

      
        
        109
        +getAll opts =

      
        
        110
        +    Effect.sendApiRequest

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

      
        
        112
        +        , method = "GET"

      
        
        113
        +        , body = Http.emptyBody

      
        
        114
        +        , onResponse = opts.onResponse

      
        
        115
        +        , decoder = D.list Note.decode

      
        
        116
        +        }

      
M web/src/Data/Note.elm
···
        15
        15
         

      
        16
        16
         

      
        17
        17
         type alias Note =

      
        18
        
        -    { content : String

      
        
        18
        +    { slug : String

      
        
        19
        +    , content : String

      
        19
        20
             , readAt : Maybe Posix

      
        20
        21
             , keepBeforeExpiration : Bool

      
        
        22
        +    , hasPassword : Bool

      
        21
        23
             , createdAt : Posix

      
        22
        24
             , expiresAt : Maybe Posix

      
        23
        25
             }

      ···
        25
        27
         

      
        26
        28
         decode : Decoder Note

      
        27
        29
         decode =

      
        28
        
        -    D.map5 Note

      
        
        30
        +    D.map7 Note

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

      
        29
        32
                 (D.field "content" D.string)

      
        30
        33
                 (D.maybe (D.field "read_at" Iso8601.decoder))

      
        31
        34
                 (D.field "keep_before_expiration" D.bool)

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

      
        32
        36
                 (D.field "created_at" Iso8601.decoder)

      
        33
        37
                 (D.maybe (D.field "expires_at" Iso8601.decoder))

      
        34
        38
         

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

      
        2
        2
             ( Effect, none, batch, map, toCmd, sendCmd, sendMsg

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

      
        4
        
        -    , sendApiRequest, sendToClipboard

      
        
        4
        +    , sendApiRequest, sendToClipboard, confirmRequest

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

      
        6
        6
             )

      
        7
        7
         

      ···
        9
        9
         

      
        10
        10
         @docs Effect, none, batch, map, toCmd, sendCmd, sendMsg

      
        11
        11
         @docs pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back

      
        12
        
        -@docs sendApiRequest, sendToClipboard

      
        
        12
        +@docs sendApiRequest, sendToClipboard, confirmRequest

      
        13
        13
         @docs signin, logout, refreshTokens, saveUser, clearUser

      
        14
        14
         

      
        15
        15
         -}

      ···
        45
        45
               -- SHARED

      
        46
        46
             | SendSharedMsg Shared.Msg.Msg

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

      
        
        48
        +    | SendConfirmRequest String

      
        48
        49
             | SendToClipboard String

      
        49
        50
             | SendApiRequest

      
        50
        51
                 { endpoint : String

      ···
        176
        177
             SendToClipboard text

      
        177
        178
         

      
        178
        179
         

      
        
        180
        +confirmRequest : String -> Effect msg

      
        
        181
        +confirmRequest msg =

      
        
        182
        +    SendConfirmRequest msg

      
        
        183
        +

      
        
        184
        +

      
        179
        185
         refreshTokens : Effect msg

      
        180
        186
         refreshTokens =

      
        181
        187
             SendSharedMsg Shared.Msg.TriggerTokenRefresh

      ···
        244
        250
                 SendToClipboard text ->

      
        245
        251
                     SendToClipboard text

      
        246
        252
         

      
        
        253
        +        SendConfirmRequest msg ->

      
        
        254
        +            SendConfirmRequest msg

      
        
        255
        +

      
        247
        256
                 SendApiRequest opts ->

      
        248
        257
                     SendApiRequest

      
        249
        258
                         { endpoint = opts.endpoint

      ···
        296
        305
         

      
        297
        306
                 SendToClipboard text ->

      
        298
        307
                     Ports.sendToClipboard text

      
        
        308
        +

      
        
        309
        +        SendConfirmRequest msg ->

      
        
        310
        +            Ports.confirmRequest msg

      
        299
        311
         

      
        300
        312
                 SendApiRequest opts ->

      
        301
        313
                     let

      
M web/src/Layouts/Header.elm
···
        100
        100
             in

      
        101
        101
             case user of

      
        102
        102
                 Auth.User.SignedIn _ ->

      
        103
        
        -            [ viewLink "Profile" Route.Path.Profile

      
        
        103
        +            [ viewLink "Dashboard" Route.Path.Dashboard

      
        
        104
        +            , viewLink "Profile" Route.Path.Profile

      
        104
        105
                     , Components.Form.button

      
        105
        106
                         { text = "Logout"

      
        106
        107
                         , onClick = UserClickedLogout

      
A web/src/Pages/Dashboard.elm
···
        
        1
        +module Pages.Dashboard exposing (Model, Msg, page)

      
        
        2
        +

      
        
        3
        +import Api exposing (Response(..))

      
        
        4
        +import Api.Note

      
        
        5
        +import Auth

      
        
        6
        +import Components.Box

      
        
        7
        +import Components.Form

      
        
        8
        +import Components.Utils

      
        
        9
        +import Data.Note exposing (Note)

      
        
        10
        +import Effect exposing (Effect)

      
        
        11
        +import Html as H exposing (Html)

      
        
        12
        +import Html.Attributes as A

      
        
        13
        +import Html.Events as E

      
        
        14
        +import Layouts

      
        
        15
        +import Page exposing (Page)

      
        
        16
        +import Ports

      
        
        17
        +import Route exposing (Route)

      
        
        18
        +import Route.Path

      
        
        19
        +import Shared

      
        
        20
        +import Time exposing (Posix)

      
        
        21
        +import Time.Format

      
        
        22
        +import View exposing (View)

      
        
        23
        +

      
        
        24
        +

      
        
        25
        +page : Auth.User -> Shared.Model -> Route () -> Page Model Msg

      
        
        26
        +page _ shared _ =

      
        
        27
        +    Page.new

      
        
        28
        +        { init = init

      
        
        29
        +        , update = update

      
        
        30
        +        , subscriptions = subscriptions

      
        
        31
        +        , view = view shared

      
        
        32
        +        }

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

      
        
        34
        +

      
        
        35
        +

      
        
        36
        +type alias Model =

      
        
        37
        +    { notes : Api.Response (List Note)

      
        
        38
        +    , noteToDeleteSlug : Maybe String

      
        
        39
        +    , apiError : Maybe Api.Error

      
        
        40
        +    }

      
        
        41
        +

      
        
        42
        +

      
        
        43
        +init : () -> ( Model, Effect Msg )

      
        
        44
        +init () =

      
        
        45
        +    ( { notes = Api.Loading

      
        
        46
        +      , noteToDeleteSlug = Nothing

      
        
        47
        +      , apiError = Nothing

      
        
        48
        +      }

      
        
        49
        +    , Api.Note.getAll { onResponse = ApiNotesResponded }

      
        
        50
        +    )

      
        
        51
        +

      
        
        52
        +

      
        
        53
        +

      
        
        54
        +-- UPDATE

      
        
        55
        +

      
        
        56
        +

      
        
        57
        +type Msg

      
        
        58
        +    = UserClickedCreateNewNote

      
        
        59
        +    | UserClickedViewNote String

      
        
        60
        +    | UserClickedDeleteNote String

      
        
        61
        +    | UserConfirmedDeleteion Bool

      
        
        62
        +    | ApiNotesResponded (Result Api.Error (List Note))

      
        
        63
        +    | ApiNoteDeleted (Result Api.Error ())

      
        
        64
        +

      
        
        65
        +

      
        
        66
        +update : Msg -> Model -> ( Model, Effect Msg )

      
        
        67
        +update msg model =

      
        
        68
        +    case msg of

      
        
        69
        +        UserClickedCreateNewNote ->

      
        
        70
        +            ( model, Effect.pushRoutePath Route.Path.Home_ )

      
        
        71
        +

      
        
        72
        +        UserClickedViewNote slug ->

      
        
        73
        +            ( model, Effect.pushRoutePath (Route.Path.Secret_Slug_ { slug = slug }) )

      
        
        74
        +

      
        
        75
        +        UserClickedDeleteNote slug ->

      
        
        76
        +            ( { model | noteToDeleteSlug = Just slug }

      
        
        77
        +            , Effect.confirmRequest "Are you sure you want to delete this note?"

      
        
        78
        +            )

      
        
        79
        +

      
        
        80
        +        UserConfirmedDeleteion ok ->

      
        
        81
        +            case ( ok, model.noteToDeleteSlug ) of

      
        
        82
        +                ( True, Just slug ) ->

      
        
        83
        +                    let

      
        
        84
        +                        newNotes =

      
        
        85
        +                            case model.notes of

      
        
        86
        +                                Success notes ->

      
        
        87
        +                                    Success (List.filter (\n -> n.slug /= slug) notes)

      
        
        88
        +

      
        
        89
        +                                _ ->

      
        
        90
        +                                    model.notes

      
        
        91
        +                    in

      
        
        92
        +                    ( { model | notes = newNotes, noteToDeleteSlug = Nothing }

      
        
        93
        +                    , Api.Note.delete { onResponse = ApiNoteDeleted, slug = slug }

      
        
        94
        +                    )

      
        
        95
        +

      
        
        96
        +                _ ->

      
        
        97
        +                    ( { model | noteToDeleteSlug = Nothing }, Effect.none )

      
        
        98
        +

      
        
        99
        +        ApiNotesResponded (Ok notes) ->

      
        
        100
        +            ( { model | notes = Api.Success notes }, Effect.none )

      
        
        101
        +

      
        
        102
        +        ApiNotesResponded (Err error) ->

      
        
        103
        +            ( { model | notes = Api.Failure error }, Effect.none )

      
        
        104
        +

      
        
        105
        +        ApiNoteDeleted (Ok _) ->

      
        
        106
        +            ( { model | apiError = Nothing }, Effect.none )

      
        
        107
        +

      
        
        108
        +        ApiNoteDeleted (Err err) ->

      
        
        109
        +            ( { model | apiError = Just err }, Effect.none )

      
        
        110
        +

      
        
        111
        +

      
        
        112
        +subscriptions : Model -> Sub Msg

      
        
        113
        +subscriptions _ =

      
        
        114
        +    Ports.confirmResponse UserConfirmedDeleteion

      
        
        115
        +

      
        
        116
        +

      
        
        117
        +

      
        
        118
        +-- VIEW

      
        
        119
        +

      
        
        120
        +

      
        
        121
        +view : Shared.Model -> Model -> View Msg

      
        
        122
        +view shared model =

      
        
        123
        +    let

      
        
        124
        +        timeFormat =

      
        
        125
        +            Time.Format.toString shared.timeZone

      
        
        126
        +    in

      
        
        127
        +    { title = "Dashboard"

      
        
        128
        +    , body =

      
        
        129
        +        [ Components.Utils.commonContainer

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

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

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

      
        
        133
        +                    , viewHeader

      
        
        134
        +                    , H.div [ A.class "p-6" ] [ viewNotes model.notes timeFormat ]

      
        
        135
        +                    ]

      
        
        136
        +                ]

      
        
        137
        +            ]

      
        
        138
        +        ]

      
        
        139
        +    }

      
        
        140
        +

      
        
        141
        +

      
        
        142
        +viewCreateNoteButton : Html Msg

      
        
        143
        +viewCreateNoteButton =

      
        
        144
        +    Components.Form.button

      
        
        145
        +        { text = "Create New Note"

      
        
        146
        +        , onClick = UserClickedCreateNewNote

      
        
        147
        +        , style = Components.Form.PrimaryReverse True

      
        
        148
        +        , disabled = False

      
        
        149
        +        }

      
        
        150
        +

      
        
        151
        +

      
        
        152
        +viewHeader : Html Msg

      
        
        153
        +viewHeader =

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

      
        
        155
        +        [ H.div [ A.class "flex justify-between items-start" ]

      
        
        156
        +            [ H.div []

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

      
        
        158
        +                , H.p [ A.class "text-gray-600 mt-2" ] [ H.text "Manage and organize all your created notes" ]

      
        
        159
        +                ]

      
        
        160
        +            , H.div [] [ viewCreateNoteButton ]

      
        
        161
        +            ]

      
        
        162
        +        ]

      
        
        163
        +

      
        
        164
        +

      
        
        165
        +viewNotes : Api.Response (List Note) -> (Posix -> String) -> Html Msg

      
        
        166
        +viewNotes apiResp timeFormat =

      
        
        167
        +    case apiResp of

      
        
        168
        +        Success notes ->

      
        
        169
        +            if List.isEmpty notes then

      
        
        170
        +                viewEmptyNoteList

      
        
        171
        +

      
        
        172
        +            else

      
        
        173
        +                H.div [ A.class "space-y-4" ]

      
        
        174
        +                    [ H.div [ A.class "pb-2 border-b border-gray-200" ]

      
        
        175
        +                        [ H.span [ A.class "text-sm text-gray-600" ] [ H.text (String.fromInt (List.length notes) ++ " note(s) ") ] ]

      
        
        176
        +                    , H.div [] (List.map (\n -> viewNoteCard n timeFormat) notes)

      
        
        177
        +                    ]

      
        
        178
        +

      
        
        179
        +        Failure err ->

      
        
        180
        +            H.text ("Something went wrong: " ++ Api.errorMessage err)

      
        
        181
        +

      
        
        182
        +        Loading ->

      
        
        183
        +            H.text "Loading notes"

      
        
        184
        +

      
        
        185
        +

      
        
        186
        +viewNoteCard : Note -> (Posix -> String) -> Html Msg

      
        
        187
        +viewNoteCard note timeFormat =

      
        
        188
        +    let

      
        
        189
        +        viewNoteTime text maybeTime =

      
        
        190
        +            Components.Utils.viewMaybe maybeTime

      
        
        191
        +                (\r ->

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

      
        
        193
        +                        [ H.p []

      
        
        194
        +                            [ H.span [ A.class "font-bold" ] [ H.text text ]

      
        
        195
        +                            , H.span [] [ H.text (timeFormat r) ]

      
        
        196
        +                            ]

      
        
        197
        +                        ]

      
        
        198
        +                )

      
        
        199
        +

      
        
        200
        +        viewNoteBadges text cond colorClasses =

      
        
        201
        +            Components.Utils.viewIf cond

      
        
        202
        +                (H.span

      
        
        203
        +                    [ A.class ("inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full " ++ colorClasses) ]

      
        
        204
        +                    [ H.span [] [ H.text text ] ]

      
        
        205
        +                )

      
        
        206
        +    in

      
        
        207
        +    H.div

      
        
        208
        +        [ A.class

      
        
        209
        +            (if note.readAt /= Nothing then

      
        
        210
        +                "border rounded-lg p-4 border-red-200 bg-red-50"

      
        
        211
        +

      
        
        212
        +             else

      
        
        213
        +                "border rounded-lg p-4 border-gray-200 hover:border-gray-300 transition-colors"

      
        
        214
        +            )

      
        
        215
        +        ]

      
        
        216
        +        [ H.div [ A.class "flex items-start justify-between" ]

      
        
        217
        +            [ H.div [ A.class "flex-1 min-w-0" ]

      
        
        218
        +                [ H.p [ A.class "text-gray-700 text-sm mb-3" ] [ H.text (truncateContent note.content) ]

      
        
        219
        +                , H.div [ A.class "flex flex-wrap items-center gap-4 text-xs text-gray-500 mb-2" ]

      
        
        220
        +                    [ H.div [ A.class "items-center" ]

      
        
        221
        +                        [ H.p []

      
        
        222
        +                            [ H.span [ A.class "font-bold" ] [ H.text "Created " ]

      
        
        223
        +                            , H.span [] [ H.text (timeFormat note.createdAt) ]

      
        
        224
        +                            ]

      
        
        225
        +                        , viewNoteTime "Read " note.readAt

      
        
        226
        +                        , viewNoteTime "Expires " note.expiresAt

      
        
        227
        +                        ]

      
        
        228
        +                    ]

      
        
        229
        +                , H.div [ A.class "flex flex-wrap gap-2" ]

      
        
        230
        +                    [ viewNoteBadges "Burn after reading" note.keepBeforeExpiration "bg-orange-100 text-orange-800"

      
        
        231
        +                    , viewNoteBadges "Has password" note.hasPassword "bg-blue-100 text-blue-800"

      
        
        232
        +                    , viewNoteBadges "Read" (note.readAt /= Nothing) "bg-red-100 text-red-100"

      
        
        233
        +                    ]

      
        
        234
        +                ]

      
        
        235
        +            , H.div [ A.class "flex items-center gap-2 ml-4" ]

      
        
        236
        +                [ H.button

      
        
        237
        +                    [ A.class "p-2 text-gray-400 hover:text-gray-600 bg-gray-50 hover:bg-gray-100 rounded-md transition-colors"

      
        
        238
        +                    , E.onClick (UserClickedViewNote note.slug)

      
        
        239
        +                    , A.title "View note"

      
        
        240
        +                    , A.type_ "button"

      
        
        241
        +                    ]

      
        
        242
        +                    [ H.text "👁️" ]

      
        
        243
        +                , H.button

      
        
        244
        +                    [ A.class "p-2 text-gray-400 text-red-300 hover:text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors disabled:opacity-50"

      
        
        245
        +                    , E.onClick (UserClickedDeleteNote note.slug)

      
        
        246
        +                    , A.title "Delete note"

      
        
        247
        +                    , A.type_ "button"

      
        
        248
        +                    ]

      
        
        249
        +                    [ H.text "🗑️" ]

      
        
        250
        +                ]

      
        
        251
        +            ]

      
        
        252
        +        ]

      
        
        253
        +

      
        
        254
        +

      
        
        255
        +truncateContent : String -> String

      
        
        256
        +truncateContent content =

      
        
        257
        +    if String.isEmpty content then

      
        
        258
        +        "<DELETED NOTE>"

      
        
        259
        +

      
        
        260
        +    else if String.length content <= 150 then

      
        
        261
        +        content

      
        
        262
        +

      
        
        263
        +    else

      
        
        264
        +        String.left 150 content ++ "..."

      
        
        265
        +

      
        
        266
        +

      
        
        267
        +viewEmptyNoteList : Html msg

      
        
        268
        +viewEmptyNoteList =

      
        
        269
        +    H.text "No notes found"

      
M web/src/Pages/Home_.elm
···
        61
        61
               , slug = Nothing

      
        62
        62
               , password = Nothing

      
        63
        63
               , expirationTime = Nothing

      
        64
        
        -      , keepBeforeExpiration = True

      
        
        64
        +      , keepBeforeExpiration = False

      
        65
        65
               , userClickedCopyLink = False

      
        66
        66
               , apiError = Nothing

      
        67
        67
               , now = Nothing

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

      
        
        1
        +port module Ports exposing (confirmRequest, confirmResponse, sendToClipboard, sendToLocalStorage)

      
        2
        2
         

      
        3
        3
         import Json.Encode

      
        4
        4
         

      ···
        7
        7
         

      
        8
        8
         

      
        9
        9
         port sendToClipboard : String -> Cmd msg

      
        
        10
        +

      
        
        11
        +

      
        
        12
        +port confirmRequest : String -> Cmd msg

      
        
        13
        +

      
        
        14
        +

      
        
        15
        +port confirmResponse : (Bool -> msg) -> Sub msg

      
M web/src/interop.js
···
        24
        24
               }

      
        25
        25
             });

      
        26
        26
           }

      
        
        27
        +

      
        
        28
        +  if (app.ports?.confirmRequest && app.ports?.confirmResponse) {

      
        
        29
        +    app.ports.confirmRequest.subscribe(msg => {

      
        
        30
        +      const res = window.confirm(msg);

      
        
        31
        +      app.ports.confirmResponse.send(res);

      
        
        32
        +    });

      
        
        33
        +  }

      
        27
        34
         };