17 files changed,
309 insertions(+),
323 deletions(-)
Author:
Olexandr Smirnov
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-07-31 15:49:21 +0300
Parent:
c3cd668
jump to
M
Taskfile.yml
··· 22 22 23 23 lint: 24 24 - golangci-lint run 25 + - task: frontend:lint 25 26 26 27 docker:up: 27 28 - docker compose up -d --build --remove-orphans ··· 36 37 test: 37 38 - task: test:unit 38 39 - task: test:e2e 40 + - task: frontend:test 39 41 40 42 test:unit: 41 43 - '{{.gotest}} --count=1 -v --short ./...'
M
web/src/Api.elm
··· 4 4 import Json.Decode 5 5 6 6 7 +type Response value 8 + = Loading 9 + | Success value 10 + | Failure Error 11 + 12 + 7 13 type Error 8 14 = HttpError 9 15 { message : String ··· 13 19 { message : String 14 20 , reason : Json.Decode.Error 15 21 } 16 - 17 - 18 -type Response value 19 - = Loading 20 - | Success value 21 - | Failure Error 22 22 23 23 24 24 errorMessage : Error -> String
M
web/src/Api/Auth.elm
··· 16 16 -> Effect msg 17 17 signin options = 18 18 let 19 - body : Encode.Value 20 19 body = 21 20 Encode.object 22 21 [ ( "email", Encode.string options.email ) ··· 40 39 -> Effect msg 41 40 signup options = 42 41 let 43 - body : Encode.Value 44 42 body = 45 43 Encode.object 46 44 [ ( "email", Encode.string options.email ) ··· 56 54 } 57 55 58 56 59 -refreshToken : 60 - { onResponse : Result Api.Error Credentials -> msg 61 - , refreshToken : String 62 - } 63 - -> Effect msg 57 +refreshToken : { onResponse : Result Api.Error Credentials -> msg, refreshToken : String } -> Effect msg 64 58 refreshToken options = 65 - let 66 - body = 67 - Encode.object [ ( "refresh_token", Encode.string options.refreshToken ) ] 68 - in 69 59 Effect.sendApiRequest 70 60 { endpoint = "/api/v1/auth/refresh-tokens" 71 61 , method = "POST" 72 - , body = Http.jsonBody body 62 + , body = Encode.object [ ( "refresh_token", Encode.string options.refreshToken ) ] |> Http.jsonBody 73 63 , onResponse = options.onResponse 74 64 , decoder = Credentials.decode 75 65 } ··· 99 89 100 90 resendVerificationEmail : { onResponse : Result Api.Error () -> msg, email : String } -> Effect msg 101 91 resendVerificationEmail options = 102 - let 103 - body = 104 - Encode.object [ ( "email", Encode.string options.email ) ] 105 - in 106 92 Effect.sendApiRequest 107 93 { endpoint = "/api/v1/auth/resend-verification-email" 108 94 , method = "POST" 109 - , body = Http.jsonBody body 95 + , body = Encode.object [ ( "email", Encode.string options.email ) ] |> Http.jsonBody 110 96 , onResponse = options.onResponse 111 97 , decoder = Decode.succeed () 112 98 }
M
web/src/Api/Note.elm
··· 20 20 -> Effect msg 21 21 create options = 22 22 let 23 - encodeMaybe : Maybe a -> String -> (a -> E.Value) -> ( String, E.Value ) 24 - encodeMaybe maybe field value = 23 + encodeMaybe : String -> (a -> E.Value) -> Maybe a -> ( String, E.Value ) 24 + encodeMaybe field value maybe = 25 25 case maybe of 26 26 Just data -> 27 27 ( field, value data ) ··· 32 32 body = 33 33 E.object 34 34 [ ( "content", E.string options.content ) 35 - , encodeMaybe options.slug "slug" E.string 36 - , encodeMaybe options.password "password" E.string 35 + , encodeMaybe "slug" E.string options.slug 36 + , encodeMaybe "password" E.string options.password 37 37 , ( "burn_before_expiration", E.bool options.burnBeforeExpiration ) 38 38 , if options.expiresAt == Time.millisToPosix 0 then 39 39 ( "expires_at", E.null )
M
web/src/Auth.elm
··· 13 13 Auth.User.User 14 14 15 15 16 -{-| Called before an auth-only page is loaded. 17 --} 18 16 onPageLoad : Shared.Model -> Route () -> Auth.Action.Action User 19 17 onPageLoad shared _ = 20 18 case shared.user of ··· 32 30 Auth.Action.loadPageWithUser credentials 33 31 34 32 35 -{-| Renders whenever `Auth.Action.loadCustomPage` is returned from `onPageLoad`. 36 --} 37 33 viewCustomPage : Shared.Model -> Route () -> View Never 38 34 viewCustomPage _ _ = 39 35 View.fromString "Loading..."
M
web/src/Components/Form.elm
··· 1 -module Components.Form exposing (input) 1 +module Components.Form exposing (ButtonStyle(..), CanBeClicked, button, input, submitButton) 2 2 3 3 import Html as H exposing (Html) 4 4 import Html.Attributes as A 5 5 import Html.Events as E 6 6 7 7 8 + 9 +-- INPUT 10 + 11 + 8 12 input : 9 - -- TODO: add `error : Maybe String`, to input to show that field is not correct and message 13 + -- TODO: add `error : Maybe String`, to show that field is not correct and message 10 14 { id : String 11 15 , field : field 12 16 , label : String ··· 59 63 Nothing -> 60 64 H.text "" 61 65 ] 66 + 67 + 68 + 69 +-- BUTTON 70 + 71 + 72 +type alias CanBeClicked = 73 + Bool 74 + 75 + 76 +type ButtonStyle 77 + = Primary CanBeClicked 78 + | Secondary CanBeClicked 79 + | SecondaryDisabled CanBeClicked 80 + | SecondaryDanger 81 + 82 + 83 +button : { text : String, disabled : Bool, onClick : msg, style : ButtonStyle } -> Html msg 84 +button opts = 85 + H.button 86 + [ A.type_ "button" 87 + , E.onClick opts.onClick 88 + , A.class (buttonStyleToClass opts.style "") 89 + , A.disabled opts.disabled 90 + ] 91 + [ H.text opts.text ] 92 + 93 + 94 +submitButton : { text : String, disabled : Bool, class : String, style : ButtonStyle } -> Html msg 95 +submitButton opts = 96 + H.button 97 + [ A.type_ "submit" 98 + , A.class (buttonStyleToClass opts.style opts.class) 99 + , A.disabled opts.disabled 100 + ] 101 + [ H.text opts.text ] 102 + 103 + 104 +buttonStyleToClass : ButtonStyle -> String -> String 105 +buttonStyleToClass style appendClasses = 106 + case style of 107 + Primary canBeClicked -> 108 + getButtonClasses canBeClicked 109 + appendClasses 110 + "px-6 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors" 111 + "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" 112 + 113 + SecondaryDanger -> 114 + "text-gray-600 hover:text-red-600 transition-colors" 115 + 116 + Secondary canBeClicked -> 117 + getButtonClasses canBeClicked 118 + appendClasses 119 + "px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors bg-green-100 border-green-300 text-green-700" 120 + "px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors border-gray-300 text-gray-700 hover:bg-gray-50" 121 + 122 + SecondaryDisabled canBeClicked -> 123 + getButtonClasses canBeClicked 124 + appendClasses 125 + "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-400 cursor-not-allowed" 126 + "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-700 hover:bg-gray-50" 127 + 128 + 129 +getButtonClasses : Bool -> String -> String -> String -> String 130 +getButtonClasses cond extend whenTrue whenFalse = 131 + let 132 + cls = 133 + if String.isEmpty extend then 134 + "" 135 + 136 + else 137 + " " ++ extend 138 + in 139 + if cond then 140 + whenTrue ++ cls 141 + 142 + else 143 + whenFalse ++ cls
A
web/src/Components/Icon.elm
··· 1 +module Components.Icon exposing (IconType(..), view) 2 + 3 +import Html as H exposing (Html) 4 +import Html.Attributes as A 5 + 6 + 7 +type IconType 8 + = NoteIcon 9 + | NotFound 10 + | Warning 11 + 12 + 13 +view : IconType -> String -> Html msg 14 +view t cls = 15 + let 16 + getHtml img = 17 + H.img [ A.src ("/static/" ++ img ++ ".svg"), A.class cls ] [] 18 + in 19 + case t of 20 + NoteIcon -> 21 + getHtml "note-icon" 22 + 23 + NotFound -> 24 + getHtml "note-not-found" 25 + 26 + Warning -> 27 + getHtml "warning"
M
web/src/Components/Utils.elm
··· 1 -module Components.Utils exposing (loadSvg, viewIf, viewMaybe) 1 +module Components.Utils exposing (commonContainer, viewIf, viewMaybe) 2 2 3 3 import Html as H exposing (Html) 4 4 import Html.Attributes as A ··· 23 23 H.text "" 24 24 25 25 26 -loadSvg : { path : String, class : String } -> Html msg 27 -loadSvg { path, class } = 28 - H.img [ A.src ("/static/" ++ path), A.class class ] [] 26 +commonContainer : List (Html msg) -> Html msg 27 +commonContainer child = 28 + H.div [ A.class "py-8 w-full max-w-4xl mx-auto " ] 29 + [ H.div [ A.class "rounded-lg border border-gray-200 shadow-sm" ] child ]
M
web/src/Effect.elm
··· 1 1 module Effect exposing 2 - ( Effect, none, batch, sendCmd, sendMsg 2 + ( Effect, none, batch, map, toCmd, sendCmd, sendMsg 3 3 , pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back 4 4 , sendApiRequest, sendToClipboard 5 5 , signin, logout, refreshTokens, saveUser, clearUser 6 - , map, toCmd 7 6 ) 8 7 9 8 {-| 10 9 11 -@docs Effect, none, batch, sendCmd, sendMsg 10 +@docs Effect, none, batch, map, toCmd, sendCmd, sendMsg 12 11 @docs pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back 13 12 @docs sendApiRequest, sendToClipboard 14 13 @docs signin, logout, refreshTokens, saveUser, clearUser 15 -@docs map, toCmd 16 14 17 15 -} 18 16 ··· 355 353 Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) 356 354 357 355 Http.BadStatus_ { statusCode } body -> 358 - case body of 359 - "" -> 360 - Err (Api.HttpError { message = "Unexpected empty response", reason = Http.BadStatus statusCode }) 356 + if String.isEmpty body then 357 + Err (Api.HttpError { message = "Unexpected empty response", reason = Http.BadStatus statusCode }) 361 358 362 - _ -> 363 - case Json.Decode.decodeString Data.Error.decode body of 364 - Ok err -> 365 - Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode }) 359 + else 360 + case Json.Decode.decodeString Data.Error.decode body of 361 + Ok err -> 362 + Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode }) 366 363 367 - Err err -> 368 - Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) 364 + Err err -> 365 + Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) 369 366 370 367 Http.BadUrl_ url -> 371 368 Err (Api.HttpError { message = "Unexpected URL format", reason = Http.BadUrl url })
M
web/src/ExpirationOptions.elm
→ web/src/Constants.elm
··· 1 -module ExpirationOptions exposing (expirationOptions) 1 +module Constants exposing (expirationOptions) 2 2 3 3 4 4 expirationOptions : List { text : String, value : Int }
M
web/src/Layouts/Header.elm
··· 1 1 module Layouts.Header exposing (Model, Msg, Props, layout) 2 2 3 3 import Auth.User 4 +import Components.Form 4 5 import Effect exposing (Effect) 5 6 import Html as H exposing (Html) 6 7 import Html.Attributes as A 7 -import Html.Events as E 8 8 import Layout exposing (Layout) 9 9 import Route exposing (Route) 10 10 import Route.Path ··· 101 101 case user of 102 102 Auth.User.SignedIn _ -> 103 103 [ viewLink "Profile" Route.Path.Profile_Me 104 - , H.button 105 - [ A.class "text-gray-600 hover:text-red-600 transition-colors" 106 - , E.onClick UserClickedLogout 107 - ] 108 - [ H.text "Logout" ] 104 + , Components.Form.button 105 + { text = "Logout" 106 + , onClick = UserClickedLogout 107 + , style = Components.Form.SecondaryDanger 108 + , disabled = False 109 + } 109 110 ] 110 111 111 112 _ -> 112 - [ viewLink "About" Route.Path.Home_ -- TODO: or add about page, or delete the link 113 - , H.a 113 + [ H.a 114 114 [ A.class "px-4 py-2 border border-gray-300 rounded-md text-black hover:bg-gray-50 transition-colors" 115 115 , Route.Path.href Route.Path.Auth 116 116 ]
M
web/src/Pages/Auth.elm
··· 1 -module Pages.Auth exposing (Banner, Model, Msg, Variant, page) 1 +module Pages.Auth exposing (Banner, FormVariant, Model, Msg, page) 2 2 3 3 import Api 4 4 import Api.Auth ··· 41 41 , password : String 42 42 , passwordAgain : String 43 43 , isSubmittingForm : Bool 44 - , formVariant : Variant 45 44 , banner : Banner 45 + , formVariant : FormVariant 46 46 , lastClicked : Maybe Posix 47 47 , now : Maybe Posix 48 48 } ··· 84 84 type Msg 85 85 = Tick Posix 86 86 | UserUpdatedInput Field String 87 - | UserChangedFormVariant Variant 87 + | UserChangedFormVariant FormVariant 88 88 | UserClickedSubmit 89 89 | UserClickedResendActivationEmail 90 90 | ApiSignInResponded (Result Api.Error Credentials) ··· 104 104 String 105 105 106 106 107 -type Variant 107 +type FormVariant 108 108 = SignIn 109 109 | SignUp 110 110 | ForgotPassword ··· 224 224 [ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ] 225 225 -- TODO: add oauth buttons 226 226 [ viewBanner model 227 - , viewHeader model.formVariant 227 + , viewBoxHeader model.formVariant 228 228 , H.div [ A.class "px-6 pb-6 space-y-4" ] 229 229 [ viewChangeVariant model.formVariant 230 230 , H.div [ A.class "border-t border-gray-200" ] [] ··· 255 255 viewVerificationBanner : Maybe Posix -> Maybe Posix -> Html Msg 256 256 viewVerificationBanner now lastClicked = 257 257 let 258 - buttonClassesBase = 259 - "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3" 260 - 261 - buttonClasses active = 262 - if active then 263 - buttonClassesBase ++ " border border-gray-300 text-gray-700 hover:bg-gray-50" 264 - 265 - else 266 - buttonClassesBase ++ " border border-gray-300 text-gray-400 cursor-not-allowed" 267 - 268 258 timeLeftSeconds = 269 259 case ( now, lastClicked ) of 270 260 ( Just now_, Just last ) -> ··· 284 274 Components.Box.successBox 285 275 [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text "Check your email!" ] 286 276 , H.p [ A.class "text-green-800 text-sm" ] [ H.text "Please verify your account to continue. We've sent a verification link to your email — click it to activate your account." ] 287 - , H.button 288 - [ A.class (buttonClasses canClick) 289 - , E.onClick UserClickedResendActivationEmail 290 - , A.disabled (not canClick) 291 - ] 292 - [ H.text "Resend verification email" ] 277 + , Components.Form.button 278 + { text = "Resend verification email" 279 + , onClick = UserClickedResendActivationEmail 280 + , disabled = not canClick 281 + , style = Components.Form.SecondaryDisabled canClick 282 + } 293 283 , Components.Utils.viewIf (not canClick) 294 - (H.p 295 - [ A.class "text-gray-600 text-xs mt-2" ] 284 + (H.p [ A.class "text-gray-600 text-xs mt-2" ] 296 285 [ H.text ("You can request a new verification email in " ++ String.fromInt timeLeftSeconds ++ " seconds.") ] 297 286 ) 298 287 ] 299 288 300 289 301 -viewHeader : Variant -> Html Msg 302 -viewHeader variant = 290 +viewBoxHeader : FormVariant -> Html Msg 291 +viewBoxHeader variant = 303 292 let 304 293 ( title, description ) = 305 294 case variant of ··· 321 310 ] 322 311 323 312 324 -viewChangeVariant : Variant -> Html Msg 313 +viewChangeVariant : FormVariant -> Html Msg 325 314 viewChangeVariant variant = 326 - let 327 - buttonClasses active = 328 - let 329 - base = 330 - "flex-1 px-4 py-2 rounded-md font-medium transition-colors" 331 - in 332 - if active then 333 - base ++ " bg-black text-white" 334 - 335 - else 336 - base ++ " bg-white text-black border border-gray-300 hover:bg-gray-50" 337 - in 338 - H.div [ A.class "flex gap-2" ] 339 - [ H.button 340 - [ A.class (buttonClasses (variant == SignIn)) 341 - , A.disabled (variant == SignIn) 342 - , E.onClick (UserChangedFormVariant SignIn) 343 - ] 344 - [ H.text "Sign In" ] 345 - , H.button 346 - [ A.class (buttonClasses (variant == SignUp)) 347 - , A.disabled (variant == SignUp) 348 - , E.onClick (UserChangedFormVariant SignUp) 349 - ] 350 - [ H.text "Sign Up" ] 315 + H.div [ A.class "flex [&>*]:flex-1 gap-2" ] 316 + [ Components.Form.button 317 + { text = "Sign In" 318 + , onClick = UserChangedFormVariant SignIn 319 + , style = Components.Form.Primary (variant == SignIn) 320 + , disabled = variant == SignIn 321 + } 322 + , Components.Form.button 323 + { text = "Sign Up" 324 + , disabled = variant == SignUp 325 + , style = Components.Form.Primary (variant == SignUp) 326 + , onClick = UserChangedFormVariant SignUp 327 + } 351 328 ] 352 329 353 330 ··· 389 366 viewFormInput : { field : Field, value : String } -> Html Msg 390 367 viewFormInput opts = 391 368 Components.Form.input 392 - { id = fromFieldToInputType opts.field 369 + { id = (fromFieldToFieldInfo opts.field).label 393 370 , field = opts.field 394 - , label = fromFieldToLabel opts.field 395 - , type_ = fromFieldToInputType opts.field 371 + , label = (fromFieldToFieldInfo opts.field).label 372 + , type_ = (fromFieldToFieldInfo opts.field).type_ 396 373 , value = opts.value 397 - , placeholder = fromFieldToLabel opts.field 374 + , placeholder = (fromFieldToFieldInfo opts.field).label 398 375 , required = True 399 376 , onInput = UserUpdatedInput opts.field 400 377 , helpText = Nothing ··· 416 393 417 394 viewSubmitButton : Model -> Html Msg 418 395 viewSubmitButton model = 419 - H.button 420 - [ A.type_ "submit" 421 - , A.disabled (isFormDisabled model) 422 - , A.class 423 - (if isFormDisabled model then 424 - "w-full px-4 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors" 425 - 426 - else 427 - "w-full px-4 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" 428 - ) 429 - ] 430 - [ H.text (fromVariantToLabel model.formVariant) ] 396 + Components.Form.submitButton 397 + { class = "w-full" 398 + , text = fromVariantToLabel model.formVariant 399 + , style = Components.Form.Primary (isFormDisabled model) 400 + , disabled = isFormDisabled model 401 + } 431 402 432 403 433 404 isFormDisabled : Model -> Bool ··· 455 426 || (model.password /= model.passwordAgain) 456 427 457 428 458 -fromVariantToLabel : Variant -> String 429 +fromVariantToLabel : FormVariant -> String 459 430 fromVariantToLabel variant = 460 431 case variant of 461 432 SignIn -> ··· 471 442 "Set new password" 472 443 473 444 474 -fromFieldToLabel : Field -> String 475 -fromFieldToLabel field = 476 - case field of 477 - Email -> 478 - "Email address" 479 - 480 - Password -> 481 - "Password" 482 - 483 - PasswordAgain -> 484 - "Confirm password" 485 - 486 - 487 -fromFieldToInputType : Field -> String 488 -fromFieldToInputType field = 445 +fromFieldToFieldInfo : Field -> { label : String, type_ : String } 446 +fromFieldToFieldInfo field = 489 447 case field of 490 448 Email -> 491 - "email" 449 + { label = "Email address", type_ = "email" } 492 450 493 451 Password -> 494 - "password" 452 + { label = "Password", type_ = "password" } 495 453 496 454 PasswordAgain -> 497 - "password" 455 + { label = "Confirm password", type_ = "password" }
M
web/src/Pages/Home_.elm
··· 5 5 import Components.Box 6 6 import Components.Form 7 7 import Components.Utils 8 +import Constants exposing (expirationOptions) 8 9 import Data.Note as Note 9 10 import Effect exposing (Effect) 10 -import ExpirationOptions exposing (expirationOptions) 11 11 import Html as H exposing (Html) 12 12 import Html.Attributes as A 13 13 import Html.Events as E ··· 49 49 } 50 50 51 51 52 - 53 --- TODO: store slug as Slug type 54 - 55 - 56 52 type PageVariant 57 53 = CreateNote 58 54 | NoteCreated String ··· 79 75 80 76 81 77 type Msg 82 - = CopyButtonReset 83 - | Tick Posix 78 + = Tick Posix 79 + | CopyButtonReset 84 80 | UserUpdatedInput Field String 85 81 | UserClickedCheckbox Bool 86 82 | UserClickedSubmit ··· 149 145 ( { model | content = content }, Effect.none ) 150 146 151 147 UserUpdatedInput Slug slug -> 152 - if slug == "" then 148 + if String.isEmpty slug then 153 149 ( { model | slug = Nothing }, Effect.none ) 154 150 155 151 else 156 152 ( { model | slug = Just slug }, Effect.none ) 157 153 158 154 UserUpdatedInput Password password -> 159 - if password == "" then 155 + if String.isEmpty password then 160 156 ( { model | password = Nothing }, Effect.none ) 161 157 162 158 else ··· 177 173 178 174 ApiCreateNoteResponded (Err error) -> 179 175 ( { model | apiError = Just error }, Effect.none ) 180 - 181 - 182 - 183 --- SUBSCRIPTIONS 184 176 185 177 186 178 subscriptions : Model -> Sub Msg ··· 204 196 205 197 view : Shared.Model -> Model -> View Msg 206 198 view shared model = 199 + let 200 + appUrl = 201 + secretUrl shared.appURL 202 + in 207 203 { title = "Onasty" 208 204 , body = 209 - [ H.div [ A.class "py-8 px-4 " ] 210 - [ H.div [ A.class "w-full max-w-4xl mx-auto" ] 211 - [ H.div [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ] 212 - [ viewHeader model.pageVariant 213 - , H.div [ A.class "p-6 space-y-6" ] 214 - [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) 215 - , case model.pageVariant of 216 - CreateNote -> 217 - viewCreateNoteForm model shared.appURL 205 + [ Components.Utils.commonContainer 206 + [ viewHeader model.pageVariant model.apiError 207 + , H.div [ A.class "p-6 space-y-6" ] 208 + [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) 209 + , case model.pageVariant of 210 + CreateNote -> 211 + viewCreateNoteForm model appUrl 218 212 219 - NoteCreated slug -> 220 - viewNoteCreated model.userClickedCopyLink shared.appURL slug 221 - ] 222 - ] 213 + NoteCreated slug -> 214 + Components.Utils.viewIf (model.apiError == Nothing) 215 + (viewNoteCreated model.userClickedCopyLink appUrl slug) 223 216 ] 224 217 ] 225 218 ] 226 219 } 227 220 228 221 229 -viewHeader : PageVariant -> Html Msg 230 -viewHeader pageVariant = 222 +viewHeader : PageVariant -> Maybe Api.Error -> Html Msg 223 +viewHeader pageVariant apiError = 231 224 H.div [ A.class "p-6 pb-4 border-b border-gray-200" ] 232 225 [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ] 233 226 [ H.text ··· 236 229 "Create a new note" 237 230 238 231 NoteCreated _ -> 239 - "Paste Created Successfully!" 232 + if apiError == Nothing then 233 + "Paste Created Successfully!" 234 + 235 + else 236 + "Could not create the note." 240 237 ) 241 238 ] 242 239 ] ··· 244 241 245 242 246 243 -- VIEW CREATE NOTE 247 --- TODO: validate form 244 +-- TODO: validate the form 248 245 249 246 250 -viewCreateNoteForm : Model -> String -> Html Msg 247 +viewCreateNoteForm : Model -> (String -> String) -> Html Msg 251 248 viewCreateNoteForm model appUrl = 252 249 H.form 253 250 [ E.onSubmit UserClickedSubmit ··· 261 258 , placeholder = "my-unique-slug" 262 259 , type_ = "text" 263 260 , helpText = Just "Leave empty to generate a random slug" 264 - , prefix = Just (secretUrl appUrl "") 261 + , prefix = Just (appUrl "") 265 262 , onInput = UserUpdatedInput Slug 266 263 , required = False 267 264 , value = Maybe.withDefault "" model.slug ··· 286 283 , viewBurnBeforeExpirationCheckbox 287 284 ] 288 285 ] 289 - , H.div [ A.class "flex justify-end" ] [ viewSubmitButton model ] 286 + , H.div [ A.class "flex justify-end" ] 287 + [ Components.Form.submitButton 288 + { text = "Create note" 289 + , style = Components.Form.Primary (isFormDisabled model) 290 + , disabled = False 291 + , class = "" 292 + } 293 + ] 290 294 ] 291 295 292 296 ··· 349 353 ] 350 354 351 355 352 -viewSubmitButton : Model -> Html Msg 353 -viewSubmitButton model = 354 - H.button 355 - [ A.type_ "submit" 356 - , A.disabled (isFormDisabled model) 357 - , A.class 358 - (if isFormDisabled model then 359 - "px-6 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors" 360 - 361 - else 362 - "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" 363 - ) 364 - ] 365 - [ H.text "Create note" ] 366 - 367 - 368 356 isFormDisabled : Model -> Bool 369 357 isFormDisabled model = 370 358 String.isEmpty model.content 371 359 372 360 373 -viewCreateNewNoteButton : Html Msg 374 -viewCreateNewNoteButton = 375 - H.button 376 - [ 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" 377 - , E.onClick UserClickedCreateNewNote 378 - ] 379 - [ H.text "Create New Paste" ] 380 - 381 - 382 361 fromFieldToName : Field -> String 383 362 fromFieldToName field = 384 363 case field of ··· 399 378 -- VIEW NOTE CREATED 400 379 401 380 402 -viewNoteCreated : Bool -> String -> String -> Html Msg 381 +viewNoteCreated : Bool -> (String -> String) -> String -> Html Msg 403 382 viewNoteCreated userClickedCopyLink appUrl slug = 404 383 H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-6" ] 405 - [ H.div [ A.class "bg-white border border-green-300 rounded-md p-4 mb-4" ] 406 - [ H.p [ A.class "text-sm text-gray-600 mb-2" ] 407 - [ H.text "Your paste is available at:" ] 408 - , H.p [ A.class "font-mono text-sm text-gray-800 break-all" ] 409 - [ H.text (secretUrl appUrl slug) ] 384 + [ H.div [ A.class "border border-green-300 rounded-md p-4 mb-4" ] 385 + [ H.p [ A.class "text-sm text-gray-600 mb-2" ] [ H.text "Your paste is available at:" ] 386 + , H.p [ A.class "font-mono text-sm text-gray-800" ] [ H.text (appUrl slug) ] 410 387 ] 411 388 , H.div [ A.class "flex gap-3" ] 412 - [ viewCopyLinkButton userClickedCopyLink 413 - , viewCreateNewNoteButton 389 + [ Components.Form.button 390 + { text = "Create New Paste" 391 + , onClick = UserClickedCreateNewNote 392 + , style = Components.Form.Primary False 393 + , disabled = False 394 + } 395 + , Components.Form.button 396 + { style = Components.Form.Secondary userClickedCopyLink 397 + , onClick = UserClickedCopyLink 398 + , disabled = userClickedCopyLink 399 + , text = 400 + if userClickedCopyLink then 401 + "Copied!" 402 + 403 + else 404 + "Copy URL" 405 + } 414 406 ] 415 407 ] 416 - 417 - 418 -viewCopyLinkButton : Bool -> Html Msg 419 -viewCopyLinkButton isClicked = 420 - let 421 - base = 422 - "px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors" 423 - in 424 - H.button 425 - [ A.class 426 - (if isClicked then 427 - base ++ " bg-green-100 border-green-300 text-green-700" 428 - 429 - else 430 - base ++ " border-gray-300 text-gray-700 hover:bg-gray-50" 431 - ) 432 - , E.onClick UserClickedCopyLink 433 - ] 434 - [ H.text 435 - (if isClicked then 436 - "Copied!" 437 - 438 - else 439 - "Copy URL" 440 - ) 441 - ]
M
web/src/Pages/Secret/Slug_.elm
··· 3 3 import Api 4 4 import Api.Note 5 5 import Components.Box 6 +import Components.Form 7 +import Components.Icon 6 8 import Components.Utils 7 9 import Data.Note exposing (Metadata, Note) 8 10 import Effect exposing (Effect) ··· 109 111 ( { model | page = NotFound, metadata = Api.Failure error }, Effect.none ) 110 112 111 113 112 - 113 --- SUBSCRIPTIONS 114 - 115 - 116 114 subscriptions : Model -> Sub Msg 117 115 subscriptions _ = 118 116 Sub.none ··· 126 124 view shared model = 127 125 { title = "View note" 128 126 , body = 129 - [ H.div 130 - [ A.class "w-full max-w-4xl mx-auto" ] 131 - [ H.div 132 - [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ] 133 - (case model.metadata of 134 - Api.Success metadata -> 135 - viewPage shared.timeZone model.slug model.page metadata model.password 127 + [ Components.Utils.commonContainer 128 + (case model.metadata of 129 + Api.Success metadata -> 130 + viewPage shared.timeZone model.slug model.page metadata model.password 136 131 137 - Api.Loading -> 138 - [ viewHeader { title = "View note", subtitle = "Loading note metadata..." } 139 - , viewOpenNote { slug = model.slug, hasPassword = False, password = Nothing, isLoading = True } 140 - ] 132 + Api.Loading -> 133 + [ viewHeader { title = "View note", subtitle = "Loading note metadata..." } 134 + , viewOpenNote { slug = model.slug, hasPassword = False, password = Nothing, isLoading = True } 135 + ] 141 136 142 - Api.Failure error -> 143 - [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } 144 - , if Api.is404 error then 145 - viewNoteNotFound model.slug 137 + Api.Failure error -> 138 + [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } 139 + , if Api.is404 error then 140 + viewNoteNotFound 146 141 147 - else 148 - Components.Box.error (Api.errorMessage error) 149 - ] 150 - ) 151 - ] 142 + else 143 + Components.Box.error (Api.errorMessage error) 144 + ] 145 + ) 152 146 ] 153 147 } 154 148 ··· 175 169 176 170 Api.Failure _ -> 177 171 [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } 178 - , viewNoteNotFound slug 172 + , viewNoteNotFound 179 173 ] 180 174 181 175 NotFound -> 182 - [ viewNoteNotFound slug ] 176 + [ viewNoteNotFound ] 183 177 184 178 185 179 ··· 203 197 (H.div [ A.class "bg-orange-50 border-b border-orange-200 p-4" ] 204 198 [ H.div [ A.class "flex items-center gap-3" ] 205 199 [ H.div [ A.class "w-6 h-6 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0" ] 206 - [ Components.Utils.loadSvg { path = "warning.svg", class = "w-4 h-4 text-orange-600" } ] 200 + [ Components.Icon.view Components.Icon.Warning "w-4 h-4 text-orange-600" ] 207 201 , H.p [ A.class "text-orange-800 text-sm font-medium" ] 208 202 [ H.text "This note was destroyed. If you need to keep it, copy it before closing this window." ] 209 203 ] ··· 219 213 ] 220 214 ] 221 215 , H.div [ A.class "flex gap-2" ] 222 - [ H.button 223 - [ E.onClick UserClickedCopyContent 224 - , 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" 225 - ] 226 - [ H.text "Copy Content" ] 216 + [ Components.Form.button 217 + { text = "Copy Content" 218 + , style = Components.Form.SecondaryDisabled False 219 + , onClick = UserClickedCopyContent 220 + , disabled = False 221 + } 227 222 ] 228 223 ] 229 224 ] ··· 234 229 -- NOTE 235 230 236 231 237 -viewNoteNotFound : String -> Html msg 238 -viewNoteNotFound slug = 232 +viewNoteNotFound : Html msg 233 +viewNoteNotFound = 239 234 H.div [ A.class "p-6" ] 240 235 [ H.div [ A.class "text-center py-12" ] 241 236 [ H.div [ A.class "w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4" ] 242 - [ Components.Utils.loadSvg { path = "note-not-found.svg", class = "w-8 h-8 text-red-500" } ] 237 + [ Components.Icon.view Components.Icon.NotFound "w-8 h-8 text-red-500" ] 243 238 , H.h2 [ A.class "text-xl font-semibold text-gray-900 mb-2" ] 244 - [ H.text ("Note " ++ slug ++ " Not Found") ] 245 - , H.div [ A.class "text-gray-600 mb-6 space-y-2" ] 246 - [ H.p [] 247 - [ H.span [ A.class "font-bold" ] [ H.text "This note may have:" ] 248 - , H.ul [ A.class "text-sm space-y-1 list-disc list-inside text-left max-w-md mx-auto" ] 249 - [ H.li [] [ H.text "Expired and been deleted" ] 250 - , H.li [] [ H.text "Have different password" ] 251 - , H.li [] [ H.text "Been deleted by the creator" ] 252 - , H.li [] [ H.text "Been burned after reading" ] 253 - , H.li [] [ H.text "Never existed or the URL is incorrect" ] 254 - ] 255 - ] 256 - ] 239 + [ H.text "Note not found" ] 257 240 ] 258 241 ] 259 242 ··· 262 245 viewOpenNote opts = 263 246 let 264 247 isDisabled = 265 - opts.hasPassword && Maybe.withDefault "" opts.password == "" 266 - 267 - buttonData = 268 - let 269 - base = 270 - "px-6 py-3 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors" 271 - in 272 - if opts.isLoading then 273 - { text = "Loading Note...", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" } 274 - 275 - else if isDisabled then 276 - { text = "View Note", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" } 277 - 278 - else 279 - { text = "View Note", class = base ++ " bg-black text-white hover:bg-gray-800" } 248 + (opts.hasPassword && Maybe.withDefault "" opts.password == "") || opts.isLoading 280 249 in 281 250 H.div [ A.class "p-6" ] 282 251 [ H.div [ A.class "text-center py-12" ] 283 252 [ H.div [ A.class "mb-6" ] 284 253 [ H.div [ A.class "w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4" ] 285 - [ Components.Utils.loadSvg { path = "note-icon.svg", class = "w-8 h-8 text-gray-400" } ] 254 + [ Components.Icon.view Components.Icon.NoteIcon "w-8 h-8 text-gray-400" ] 286 255 , H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-2" ] [ H.text opts.slug ] 287 256 , H.p [ A.class "text-gray-600 mb-6" ] [ H.text "You're about read and destroy the note." ] 288 257 ] ··· 291 260 , A.class "max-w-sm mx-auto space-y-4" 292 261 ] 293 262 [ Components.Utils.viewIf opts.hasPassword 294 - (H.div 295 - [ A.class "space-y-2" ] 296 - [ H.label 297 - [ A.class "block text-sm font-medium text-gray-700 text-left" ] 298 - [ H.text "Password" ] 263 + (H.div [ A.class "space-y-2" ] 264 + [ H.label [ A.class "block text-sm font-medium text-gray-700 text-left" ] [ H.text "Password" ] 299 265 , H.input 300 266 [ E.onInput UserUpdatedPassword 301 267 , 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" ··· 303 269 [] 304 270 ] 305 271 ) 306 - , H.button 307 - [ A.class buttonData.class 308 - , A.type_ "submit" 309 - , A.disabled isDisabled 310 - ] 311 - [ H.text buttonData.text ] 272 + , Components.Form.submitButton 273 + { text = 274 + if opts.isLoading then 275 + "Loading Note..." 276 + 277 + else 278 + "View Note" 279 + , style = Components.Form.Primary isDisabled 280 + , disabled = isDisabled 281 + , class = "py-3" 282 + } 312 283 ] 313 284 ] 314 285 ]
M
web/tests/UnitTests/Data/Note.elm
··· 11 11 describe "Data.Note" 12 12 [ test "decodeCreateResponse" 13 13 (\_ -> 14 - "{\"slug\":\"the.note-slug\"}" 14 + """ {"slug":"the.note-slug"} """ 15 15 |> D.decodeString Data.Note.decodeCreateResponse 16 16 |> Expect.ok 17 17 ) 18 18 , test "decodeMetadata" 19 19 (\_ -> 20 20 """ 21 - { 22 - "created_at": "2023-10-01T12:00:00Z", 23 - "has_password": false 24 - } 25 - """ 21 + { 22 + "created_at": "2023-10-01T12:00:00Z", 23 + "has_password": false 24 + } 25 + """ 26 26 |> D.decodeString Data.Note.decodeMetadata 27 27 |> Expect.ok 28 28 )