9 files changed,
334 insertions(+),
8 deletions(-)
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/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