15 files changed,
573 insertions(+),
36 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-06-25 13:47:49 +0300
Parent:
67a69fb
jump to
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.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/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 + ]