8 files changed,
527 insertions(+),
21 deletions(-)
Author:
Smirnov Olexandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-07-01 13:25:47 +0300
Parent:
1994b67
M
web/src/Api.elm
··· 1 -module Api exposing (Error(..), Response(..), errorMessage) 1 +module Api exposing (Error(..), Response(..), errorMessage, is404) 2 2 3 3 import Http 4 4 import Json.Decode ··· 29 29 30 30 JsonDecodeError err -> 31 31 err.message 32 + 33 + 34 +is404 : Error -> Bool 35 +is404 error = 36 + case error of 37 + HttpError { reason } -> 38 + reason == Http.BadStatus 404 39 + 40 + _ -> 41 + False
M
web/src/Api/Note.elm
··· 1 -module Api.Note exposing (create) 1 +module Api.Note exposing (create, get, getMetadata) 2 2 3 3 import Api 4 -import Data.Note as Note exposing (CreateResponse) 4 +import Data.Note as Note exposing (CreateResponse, Metadata, Note) 5 5 import Effect exposing (Effect) 6 6 import Http 7 7 import ISO8601 8 8 import Json.Encode as E 9 9 import Time exposing (Posix) 10 +import Url 10 11 11 12 12 13 create : ··· 20 21 -> Effect msg 21 22 create options = 22 23 let 24 + encodeMaybe : Maybe a -> b -> (a -> E.Value) -> ( b, E.Value ) 25 + encodeMaybe maybeData field value = 26 + case maybeData of 27 + Just data -> 28 + ( field, value data ) 29 + 30 + Nothing -> 31 + ( field, E.null ) 32 + 23 33 body : E.Value 24 34 body = 25 35 E.object 26 36 [ ( "content", E.string options.content ) 27 - , case options.slug of 28 - Just slug -> 29 - ( "slug", E.string slug ) 30 - 31 - Nothing -> 32 - ( "slug", E.null ) 33 - , case options.password of 34 - Just password -> 35 - ( "password", E.string password ) 36 - 37 - Nothing -> 38 - ( "password", E.null ) 37 + , encodeMaybe options.slug "slug" E.string 38 + , encodeMaybe options.password "password" E.string 39 39 , ( "burn_before_expiration", E.bool options.burnBeforeExpiration ) 40 40 , if options.expiresAt == Time.millisToPosix 0 then 41 41 ( "expires_at", E.null ) ··· 56 56 , onResponse = options.onResponse 57 57 , decoder = Note.decodeCreateResponse 58 58 } 59 + 60 + 61 +get : 62 + { onResponse : Result Api.Error Note -> msg 63 + , slug : String 64 + , password : Maybe String 65 + } 66 + -> Effect msg 67 +get options = 68 + Effect.sendApiRequest 69 + { endpoint = 70 + "/api/v1/note/" 71 + ++ options.slug 72 + ++ (case options.password of 73 + Just p -> 74 + "?password=" ++ Url.percentEncode p 75 + 76 + Nothing -> 77 + "" 78 + ) 79 + , method = "GET" 80 + , body = Http.emptyBody 81 + , onResponse = options.onResponse 82 + , decoder = Note.decode 83 + } 84 + 85 + 86 +getMetadata : 87 + { onResponse : Result Api.Error Metadata -> msg 88 + , slug : String 89 + } 90 + -> Effect msg 91 +getMetadata options = 92 + Effect.sendApiRequest 93 + { endpoint = "/api/v1/note/" ++ options.slug ++ "/meta" 94 + , method = "GET" 95 + , body = Http.emptyBody 96 + , onResponse = options.onResponse 97 + , decoder = Note.decodeMetadata 98 + }
A
web/src/Components/Note.elm
··· 1 +module Components.Note exposing (noteIconSvg, noteNotFoundSvg, warningSvg) 2 + 3 +import Svg exposing (Svg) 4 +import Svg.Attributes as A 5 + 6 + 7 +noteIconSvg : Svg msg 8 +noteIconSvg = 9 + Svg.svg 10 + [ A.class "w-8 h-8 text-gray-400" 11 + , A.fill "none" 12 + , A.stroke "currentColor" 13 + , A.viewBox "0 0 24 24" 14 + ] 15 + [ Svg.path 16 + [ A.d "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 17 + , A.strokeWidth "2" 18 + , A.strokeLinecap "round" 19 + , A.strokeLinejoin "round" 20 + ] 21 + [] 22 + ] 23 + 24 + 25 +noteNotFoundSvg : Svg msg 26 +noteNotFoundSvg = 27 + Svg.svg 28 + [ A.class "w-8 h-8 text-red-500" 29 + , A.fill "none" 30 + , A.stroke "currentColor" 31 + , A.viewBox "0 0 24 24" 32 + ] 33 + [ Svg.path 34 + [ A.d "M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 35 + , A.strokeWidth "2" 36 + , A.strokeLinecap "round" 37 + , A.strokeLinejoin "round" 38 + ] 39 + [] 40 + , Svg.path 41 + [ A.d "M6 18L18 6M6 6l12 12" 42 + , A.strokeWidth "2" 43 + , A.strokeLinecap "round" 44 + , A.strokeLinejoin "round" 45 + ] 46 + [] 47 + ] 48 + 49 + 50 +warningSvg : Svg msg 51 +warningSvg = 52 + Svg.svg 53 + [ A.class "w-4 h-4 text-orange-600" 54 + , A.fill "none" 55 + , A.stroke "currentColor" 56 + , A.viewBox "0 0 24 24" 57 + ] 58 + [ Svg.path 59 + [ A.d "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z" 60 + , A.strokeWidth "2" 61 + , A.strokeLinecap "round" 62 + , A.strokeLinejoin "round" 63 + ] 64 + [] 65 + ]
M
web/src/Data/Note.elm
··· 1 -module Data.Note exposing (CreateResponse, decodeCreateResponse) 1 +module Data.Note exposing (CreateResponse, Metadata, Note, decode, decodeCreateResponse, decodeMetadata) 2 2 3 3 import Json.Decode as D exposing (Decoder) 4 4 ··· 11 11 decodeCreateResponse = 12 12 D.map CreateResponse 13 13 (D.field "slug" D.string) 14 + 15 + 16 +type alias Note = 17 + { content : String 18 + , readAt : Maybe String -- TODO: use Posix 19 + , burnBeforeExpiration : Bool 20 + , createdAt : String -- TODO: use Posix 21 + , expiresAt : Maybe String -- TODO: use Posix 22 + } 23 + 24 + 25 +decode : Decoder Note 26 +decode = 27 + D.map5 Note 28 + (D.field "content" D.string) 29 + (D.maybe (D.field "read_at" D.string)) 30 + (D.field "burn_before_expiration" D.bool) 31 + (D.field "created_at" D.string) 32 + (D.maybe (D.field "expires_at" D.string)) 33 + 34 + 35 +type alias Metadata = 36 + { createdAt : String -- TODO: use Posix 37 + , hasPassword : Bool 38 + } 39 + 40 + 41 +decodeMetadata : Decoder Metadata 42 +decodeMetadata = 43 + D.map2 Metadata 44 + (D.field "created_at" D.string) 45 + (D.field "has_password" D.bool)
M
web/src/Effect.elm
··· 377 377 Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) 378 378 379 379 Http.BadStatus_ { statusCode } body -> 380 - case Json.Decode.decodeString Data.Error.decode body of 381 - Ok err -> 382 - Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode }) 380 + case body of 381 + "" -> 382 + Err (Api.HttpError { message = "Unexpected empty response", reason = Http.BadStatus statusCode }) 383 + 384 + _ -> 385 + case Json.Decode.decodeString Data.Error.decode body of 386 + Ok err -> 387 + Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode }) 383 388 384 - Err err -> 385 - Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) 389 + Err err -> 390 + Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) 386 391 387 392 Http.BadUrl_ url -> 388 393 Err (Api.HttpError { message = "Unexpected URL format", reason = Http.BadUrl url })
A
web/src/Pages/Secret/Slug_.elm
··· 1 +module Pages.Secret.Slug_ exposing (Model, Msg, PageVariant, page) 2 + 3 +import Api 4 +import Api.Note 5 +import Components.Error 6 +import Components.Note 7 +import Data.Note exposing (Metadata, Note) 8 +import Effect exposing (Effect) 9 +import Html as H exposing (Html) 10 +import Html.Attributes as A 11 +import Html.Events as E 12 +import Layouts 13 +import Page exposing (Page) 14 +import Route exposing (Route) 15 +import Shared 16 +import View exposing (View) 17 + 18 + 19 +page : Shared.Model -> Route { slug : String } -> Page Model Msg 20 +page _ route = 21 + Page.new 22 + { init = init route.params.slug 23 + , update = update 24 + , subscriptions = subscriptions 25 + , view = view 26 + } 27 + |> Page.withLayout (\_ -> Layouts.Header {}) 28 + 29 + 30 + 31 +-- INIT 32 + 33 + 34 +type PageVariant 35 + = RequestNote 36 + | ShowNote (Api.Response Note) 37 + | NotFound 38 + 39 + 40 +type alias Model = 41 + { page : PageVariant 42 + , metadata : Api.Response Metadata 43 + , slug : String 44 + , password : Maybe String 45 + } 46 + 47 + 48 +init : String -> () -> ( Model, Effect Msg ) 49 +init slug () = 50 + ( { page = RequestNote 51 + , metadata = Api.Loading 52 + , slug = slug 53 + , password = Nothing 54 + } 55 + , Api.Note.getMetadata 56 + { onResponse = ApiGetMetadataResponded 57 + , slug = slug 58 + } 59 + ) 60 + 61 + 62 + 63 +-- UPDATE 64 + 65 + 66 +type Msg 67 + = UserClickedViewNote 68 + | UserClickedCopyContent 69 + | UserUpdatedPassword String 70 + | ApiGetNoteResponded (Result Api.Error Note) 71 + | ApiGetMetadataResponded (Result Api.Error Metadata) 72 + 73 + 74 +update : Msg -> Model -> ( Model, Effect Msg ) 75 +update msg model = 76 + case msg of 77 + UserClickedViewNote -> 78 + ( { model | page = ShowNote Api.Loading } 79 + , Api.Note.get 80 + { onResponse = ApiGetNoteResponded 81 + , password = model.password 82 + , slug = model.slug 83 + } 84 + ) 85 + 86 + UserClickedCopyContent -> 87 + case model.page of 88 + ShowNote (Api.Success note) -> 89 + ( model, Effect.sendToClipboard note.content ) 90 + 91 + _ -> 92 + ( model, Effect.none ) 93 + 94 + UserUpdatedPassword password -> 95 + ( { model | password = Just password }, Effect.none ) 96 + 97 + ApiGetNoteResponded (Ok note) -> 98 + ( { model | page = ShowNote (Api.Success note) }, Effect.none ) 99 + 100 + ApiGetNoteResponded (Err error) -> 101 + ( { model | page = ShowNote (Api.Failure error) }, Effect.none ) 102 + 103 + ApiGetMetadataResponded (Ok metadata) -> 104 + ( { model | metadata = Api.Success metadata }, Effect.none ) 105 + 106 + ApiGetMetadataResponded (Err error) -> 107 + ( { model | page = NotFound, metadata = Api.Failure error }, Effect.none ) 108 + 109 + 110 + 111 +-- SUBSCRIPTIONS 112 + 113 + 114 +subscriptions : Model -> Sub Msg 115 +subscriptions _ = 116 + Sub.none 117 + 118 + 119 + 120 +-- VIEW 121 + 122 + 123 +view : Model -> View Msg 124 +view model = 125 + { title = "View note" 126 + , body = 127 + [ H.div 128 + [ A.class "w-full max-w-4xl mx-auto" ] 129 + [ H.div 130 + [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ] 131 + (case model.metadata of 132 + Api.Success metadata -> 133 + viewPage model.slug model.page metadata model.password 134 + 135 + Api.Loading -> 136 + [ viewHeader { title = "View note", subtitle = "Loading note metadata..." } 137 + , viewOpenNote { slug = model.slug, hasPassword = False, password = Nothing, isLoading = True } 138 + ] 139 + 140 + Api.Failure error -> 141 + [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } 142 + , if Api.is404 error then 143 + viewNoteNotFound model.slug 144 + 145 + else 146 + Components.Error.error (Api.errorMessage error) 147 + ] 148 + ) 149 + ] 150 + ] 151 + } 152 + 153 + 154 +viewPage : String -> PageVariant -> Metadata -> Maybe String -> List (Html Msg) 155 +viewPage slug variant metadata password = 156 + case variant of 157 + RequestNote -> 158 + [ viewHeader { title = "View note", subtitle = "Click the button below to view the note content" } 159 + , viewOpenNote { slug = slug, hasPassword = metadata.hasPassword, password = password, isLoading = False } 160 + ] 161 + 162 + ShowNote apiResp -> 163 + case apiResp of 164 + Api.Success note -> 165 + [ viewShowNoteHeader slug note 166 + , viewNoteContent note 167 + ] 168 + 169 + Api.Loading -> 170 + [ viewHeader { title = "View note", subtitle = "Click the button below to view the note content" } 171 + , viewOpenNote { slug = slug, hasPassword = metadata.hasPassword, password = password, isLoading = True } 172 + ] 173 + 174 + Api.Failure _ -> 175 + [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } 176 + , viewNoteNotFound slug 177 + ] 178 + 179 + NotFound -> 180 + [ viewNoteNotFound slug ] 181 + 182 + 183 + 184 +-- HEADER 185 + 186 + 187 +viewHeader : { title : String, subtitle : String } -> Html msg 188 +viewHeader options = 189 + H.div [ A.class "p-6 pb-4 border-b border-gray-200" ] 190 + [ H.h1 191 + [ A.class "text-2xl font-bold text-gray-900" ] 192 + [ H.text options.title ] 193 + , H.p [ A.class "text-gray-600 mt-2" ] [ H.text options.subtitle ] 194 + ] 195 + 196 + 197 +viewShowNoteHeader : String -> Note -> Html Msg 198 +viewShowNoteHeader slug note = 199 + H.div [] 200 + [ if note.burnBeforeExpiration then 201 + H.div [ A.class "bg-orange-50 border-b border-orange-200 p-4" ] 202 + [ H.div [ A.class "flex items-center gap-3" ] 203 + [ H.div [ A.class "w-6 h-6 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0" ] 204 + [ Components.Note.warningSvg ] 205 + , H.p [ A.class "text-orange-800 text-sm font-medium" ] 206 + [ H.text "This note was destroyed. If you need to keep it, copy it before closing this window." ] 207 + ] 208 + ] 209 + 210 + else 211 + H.text "" 212 + , H.div [ A.class "p-6 pb-4 border-b border-gray-200" ] 213 + [ H.div [ A.class "flex justify-between items-start" ] 214 + [ H.div [] 215 + [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text ("Note: " ++ slug) ] 216 + , H.div [ A.class "text-sm text-gray-500 mt-2 space-y-1" ] 217 + [ H.p [] [ H.text ("Created: " ++ note.createdAt) ] 218 + , case note.expiresAt of 219 + Just expiresAt -> 220 + -- TODO: format time properly 221 + H.p [] [ H.text ("Expires at: " ++ expiresAt) ] 222 + 223 + Nothing -> 224 + H.text "" 225 + ] 226 + ] 227 + , H.div [ A.class "flex gap-2" ] 228 + [ H.button 229 + [ E.onClick UserClickedCopyContent 230 + , A.class "px-3 py-2 text-sm border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors" 231 + ] 232 + [ H.text "Copy Content" ] 233 + ] 234 + ] 235 + ] 236 + ] 237 + 238 + 239 + 240 +-- NOTE 241 + 242 + 243 +viewNoteNotFound : String -> Html msg 244 +viewNoteNotFound slug = 245 + H.div [ A.class "p-6" ] 246 + [ H.div [ A.class "text-center py-12" ] 247 + [ H.div [ A.class "w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4" ] 248 + [ Components.Note.noteNotFoundSvg ] 249 + , H.h2 [ A.class "text-xl font-semibold text-gray-900 mb-2" ] 250 + [ H.text ("Note " ++ slug ++ " Not Found") ] 251 + , H.div [ A.class "text-gray-600 mb-6 space-y-2" ] 252 + [ H.p [] 253 + [ H.span [ A.class "font-bold" ] [ H.text "This note may have:" ] 254 + , H.ul [ A.class "text-sm space-y-1 list-disc list-inside text-left max-w-md mx-auto" ] 255 + [ H.li [] [ H.text "Expired and been deleted" ] 256 + , H.li [] [ H.text "Have different password" ] 257 + , H.li [] [ H.text "Been deleted by the creator" ] 258 + , H.li [] [ H.text "Been burned after reading" ] 259 + , H.li [] [ H.text "Never existed or the URL is incorrect" ] 260 + ] 261 + ] 262 + ] 263 + ] 264 + ] 265 + 266 + 267 +viewOpenNote : 268 + { slug : String 269 + , hasPassword : Bool 270 + , isLoading : Bool 271 + , password : Maybe String 272 + } 273 + -> Html Msg 274 +viewOpenNote opts = 275 + let 276 + isDisabled : Bool 277 + isDisabled = 278 + opts.hasPassword && Maybe.withDefault "" opts.password == "" 279 + 280 + buttonData : { text : String, class : String } 281 + buttonData = 282 + let 283 + base : String 284 + base = 285 + "px-6 py-3 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors" 286 + in 287 + if opts.isLoading then 288 + { text = "Loading Note...", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" } 289 + 290 + else if isDisabled then 291 + { text = "View Note", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" } 292 + 293 + else 294 + { text = "View Note", class = base ++ " bg-black text-white hover:bg-gray-800" } 295 + in 296 + H.div [ A.class "p-6" ] 297 + [ H.div [ A.class "text-center py-12" ] 298 + [ H.div [ A.class "mb-6" ] 299 + [ H.div [ A.class "w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4" ] 300 + [ Components.Note.noteIconSvg ] 301 + , H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-2" ] [ H.text opts.slug ] 302 + , H.p [ A.class "text-gray-600 mb-6" ] [ H.text "You're about read and destroy the note." ] 303 + ] 304 + , H.form 305 + [ E.onSubmit UserClickedViewNote 306 + , A.class "max-w-sm mx-auto space-y-4" 307 + ] 308 + [ if opts.hasPassword then 309 + H.div 310 + [ A.class "space-y-2" ] 311 + [ H.label 312 + [ A.class "block text-sm font-medium text-gray-700 text-left" ] 313 + [ H.text "Password" ] 314 + , H.input 315 + [ E.onInput UserUpdatedPassword 316 + , A.class "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent" 317 + ] 318 + [] 319 + ] 320 + 321 + else 322 + H.text "" 323 + , H.button 324 + [ A.class buttonData.class 325 + , A.type_ "submit" 326 + , A.disabled isDisabled 327 + ] 328 + [ H.text buttonData.text ] 329 + ] 330 + ] 331 + ] 332 + 333 + 334 +viewNoteContent : Note -> Html msg 335 +viewNoteContent note = 336 + H.div [ A.class "p-6" ] 337 + [ H.div [ A.class "bg-gray-50 border border-gray-200 rounded-md p-4" ] 338 + [ H.pre 339 + [ A.class "whitespace-pre-wrap font-mono text-sm text-gray-800 overflow-x-auto" ] 340 + [ H.text note.content ] 341 + ] 342 + ]
M
web/tests/UnitTests/Data/Note.elm
··· 15 15 |> D.decodeString Data.Note.decodeCreateResponse 16 16 |> Expect.equal (Ok { slug = "the.note-slug" }) 17 17 ) 18 + , test "decodeMetadata" 19 + (\_ -> 20 + """ 21 + { 22 + "created_at": "2023-10-01T12:00:00Z", 23 + "has_password": false 24 + } 25 + """ 26 + |> D.decodeString Data.Note.decodeMetadata 27 + |> Expect.equal (Ok { createdAt = "2023-10-01T12:00:00Z", hasPassword = False }) 28 + ) 18 29 ]