@@ -12,7 +12,6 @@ "elm/core": "1.0.5",
"elm/html": "1.0.0", "elm/http": "2.0.0", "elm/json": "1.1.3", - "elm/svg": "1.0.1", "elm/time": "1.0.0", "elm/url": "1.0.0", "rtfeldman/elm-iso8601-date-strings": "1.1.4",
@@ -63,10 +63,8 @@ }
-> Effect msg refreshToken options = let - body : Encode.Value body = - Encode.object - [ ( "refresh_token", Encode.string options.refreshToken ) ] + Encode.object [ ( "refresh_token", Encode.string options.refreshToken ) ] in Effect.sendApiRequest { endpoint = "/api/v1/auth/refresh-tokens"
@@ -21,16 +21,15 @@ }
-> Effect msg create options = let - encodeMaybe : Maybe a -> b -> (a -> E.Value) -> ( b, E.Value ) - encodeMaybe maybeData field value = - case maybeData of + encodeMaybe : Maybe a -> String -> (a -> E.Value) -> ( String, E.Value ) + encodeMaybe maybe field value = + case maybe of Just data -> ( field, value data ) Nothing -> ( field, E.null ) - body : E.Value body = E.object [ ( "content", E.string options.content )@@ -41,11 +40,7 @@ , if options.expiresAt == Time.millisToPosix 0 then
( "expires_at", E.null ) else - ( "expires_at" - , options.expiresAt - |> Iso8601.fromTime - |> E.string - ) + ( "expires_at", options.expiresAt |> Iso8601.fromTime |> E.string ) ] in Effect.sendApiRequest
@@ -0,0 +1,61 @@
+module Components.Form exposing (input) + +import Html as H exposing (Html) +import Html.Attributes as A +import Html.Events as E + + +input : + -- TODO: add `error : Maybe String`, to input to show that field is not correct and message + { id : String + , field : field + , label : String + , type_ : String + , value : String + , placeholder : String + , required : Bool + , helpText : Maybe String + , prefix : Maybe String + , onInput : String -> msg + } + -> Html msg +input opts = + H.div [ A.class "space-y-2" ] + [ H.label + [ A.for opts.id + , A.class "block text-sm font-medium text-gray-700" + ] + [ H.text opts.label ] + , H.div + [ A.class + (if opts.prefix /= Nothing then + "flex items-center" + + else + "" + ) + ] + [ case opts.prefix of + Just prefix -> + H.span [ A.class "text-gray-500 text-md mr-2 whitespace-nowrap" ] [ H.text prefix ] + + Nothing -> + H.text "" + , H.input + [ 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" + , A.type_ opts.type_ + , A.value opts.value + , A.id opts.id + , A.placeholder opts.placeholder + , A.required opts.required + , E.onInput opts.onInput + ] + [] + ] + , case opts.helpText of + Just help -> + H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text help ] + + Nothing -> + H.text "" + ]
@@ -1,65 +0,0 @@
-module Components.Note exposing (noteIconSvg, noteNotFoundSvg, warningSvg) - -import Svg exposing (Svg) -import Svg.Attributes as A - - -noteIconSvg : Svg msg -noteIconSvg = - Svg.svg - [ A.class "w-8 h-8 text-gray-400" - , A.fill "none" - , A.stroke "currentColor" - , A.viewBox "0 0 24 24" - ] - [ Svg.path - [ 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" - , A.strokeWidth "2" - , A.strokeLinecap "round" - , A.strokeLinejoin "round" - ] - [] - ] - - -noteNotFoundSvg : Svg msg -noteNotFoundSvg = - Svg.svg - [ A.class "w-8 h-8 text-red-500" - , A.fill "none" - , A.stroke "currentColor" - , A.viewBox "0 0 24 24" - ] - [ Svg.path - [ 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" - , A.strokeWidth "2" - , A.strokeLinecap "round" - , A.strokeLinejoin "round" - ] - [] - , Svg.path - [ A.d "M6 18L18 6M6 6l12 12" - , A.strokeWidth "2" - , A.strokeLinecap "round" - , A.strokeLinejoin "round" - ] - [] - ] - - -warningSvg : Svg msg -warningSvg = - Svg.svg - [ A.class "w-4 h-4 text-orange-600" - , A.fill "none" - , A.stroke "currentColor" - , A.viewBox "0 0 24 24" - ] - [ Svg.path - [ 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" - , A.strokeWidth "2" - , A.strokeLinecap "round" - , A.strokeLinejoin "round" - ] - [] - ]
@@ -0,0 +1,28 @@
+module Components.Utils exposing (loadSvg, viewIf, viewMaybe) + +import Html as H exposing (Html) +import Html.Attributes as A + + +viewIf : Bool -> Html msg -> Html msg +viewIf condition html = + if condition then + html + + else + H.text "" + + +viewMaybe : Maybe a -> (a -> Html msg) -> Html msg +viewMaybe maybeValue toHtml = + case maybeValue of + Just value -> + toHtml value + + Nothing -> + H.text "" + + +loadSvg : { path : String, class : String } -> Html msg +loadSvg { path, class } = + H.img [ A.src ("/static/" ++ path), A.class class ] []
@@ -1,14 +1,4 @@
-module Data.Credentials exposing - ( Credentials - , decode - ) - -{-| - -@docs Credentials -@docs decode - --} +module Data.Credentials exposing (Credentials, decode) import Json.Decode as Decode exposing (Decoder)
@@ -1,31 +1,17 @@
module Effect exposing - ( Effect - , none, batch - , sendCmd, sendMsg - , pushRoute, replaceRoute - , pushRoutePath, replaceRoutePath - , loadExternalUrl, back - , sendApiRequest, refreshTokens - , sendToClipboard - , signin, logout, saveUser, clearUser + ( Effect, none, batch, sendCmd, sendMsg + , pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back + , sendApiRequest, sendToClipboard + , signin, logout, refreshTokens, saveUser, clearUser , map, toCmd ) {-| -@docs Effect - -@docs none, batch -@docs sendCmd, sendMsg - -@docs pushRoute, replaceRoute -@docs pushRoutePath, replaceRoutePath -@docs loadExternalUrl, back - -@docs sendApiRequest, refreshTokens -@docs sendToClipboard -@docs signin, logout, saveUser, clearUser - +@docs Effect, none, batch, sendCmd, sendMsg +@docs pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back +@docs sendApiRequest, sendToClipboard +@docs signin, logout, refreshTokens, saveUser, clearUser @docs map, toCmd -}@@ -62,16 +48,13 @@ -- SHARED
| SendSharedMsg Shared.Msg.Msg | SendToLocalStorage { key : String, value : Json.Encode.Value } | SendToClipboard String - | SendApiRequest (HttpRequestDetails msg) - - -type alias HttpRequestDetails msg = - { endpoint : String - , method : String - , body : Http.Body - , decoder : Json.Decode.Decoder msg - , onHttpError : Api.Error -> msg - } + | SendApiRequest + { endpoint : String + , method : String + , body : Http.Body + , decoder : Json.Decode.Decoder msg + , onHttpError : Api.Error -> msg + }@@ -230,9 +213,6 @@
-- INTERNALS -{-| Elm Land depends on this function to connect pages and layouts -together into the overall app. --} map : (msg1 -> msg2) -> Effect msg1 -> Effect msg2 map fn effect = case effect of@@ -276,8 +256,6 @@ , onHttpError = \err -> fn (opts.onHttpError err)
} -{-| Elm Land depends on this function to perform your effects. --} toCmd : { key : Browser.Navigation.Key , url : Url@@ -351,14 +329,14 @@
Err err -> opts.onHttpError err ) - (\resp -> fromHttpResponseToCustomError opts.decoder resp) + (\resp -> httpResponseToCustomError opts.decoder resp) , timeout = Just (1000 * 60) -- 60 second timeout , tracker = Nothing } -fromHttpResponseToCustomError : Json.Decode.Decoder msg -> Http.Response String -> Result Api.Error msg -fromHttpResponseToCustomError decoder response = +httpResponseToCustomError : Json.Decode.Decoder msg -> Http.Response String -> Result Api.Error msg +httpResponseToCustomError decoder response = case response of Http.GoodStatus_ _ body -> case
@@ -1,11 +1,7 @@
-module ExpirationOptions exposing (ExpiresAt, expirationOptions) - - -type alias ExpiresAt = - { text : String, value : Int } +module ExpirationOptions exposing (expirationOptions) -expirationOptions : List ExpiresAt +expirationOptions : List { text : String, value : Int } expirationOptions = [ { text = "Never expires (default)", value = 0 } , { text = "1 hour", value = 60 * 60 * 1000 }
@@ -9,11 +9,9 @@ -}
isExpired : Time.Posix -> String -> Bool isExpired now token = let - expirationThreshold : number expirationThreshold = 40 * 1000 - timeDiff : Int timeDiff = getTokenExpiration token |> (\expiration -> expiration - Time.posixToMillis now)@@ -21,10 +19,6 @@ in
timeDiff <= expirationThreshold -{-| Extracts the expiration time (in millis) from a JWT token. -Returns 0 if cannot parse token. --} getTokenExpiration : String -> Int getTokenExpiration token = - Jwt.getTokenExpirationMillis token - |> Result.withDefault 0 + Jwt.getTokenExpirationMillis token |> Result.withDefault 0
@@ -93,44 +93,26 @@
viewNav : Auth.User.SignInStatus -> List (Html Msg) viewNav user = + let + viewLink text path = + H.a [ A.class "text-gray-600 hover:text-black transition-colors", Route.Path.href path ] + [ H.text text ] + in case user of Auth.User.SignedIn _ -> - viewSignedInNav - - Auth.User.NotSignedIn -> - viewNotSignedInNav - - Auth.User.RefreshingTokens -> - viewNotSignedInNav - - -viewSignedInNav : List (Html Msg) -viewSignedInNav = - [ viewLink "Profile" Route.Path.Profile_Me - , H.button - [ A.class "text-gray-600 hover:text-red-600 transition-colors" - , E.onClick UserClickedLogout - ] - [ H.text "Logout" ] - ] - - -viewNotSignedInNav : List (Html Msg) -viewNotSignedInNav = - -- TODO: or add about page, or delete the link - [ viewLink "About" Route.Path.Home_ - , H.a - [ A.class "px-4 py-2 border border-gray-300 rounded-md text-black hover:bg-gray-50 transition-colors" - , Route.Path.href Route.Path.Auth - ] - [ H.text "Sign In/Up" ] - ] + [ viewLink "Profile" Route.Path.Profile_Me + , H.button + [ A.class "text-gray-600 hover:text-red-600 transition-colors" + , E.onClick UserClickedLogout + ] + [ H.text "Logout" ] + ] - -viewLink : String -> Route.Path.Path -> Html Msg -viewLink text path = - H.a - [ A.class "text-gray-600 hover:text-black transition-colors" - , Route.Path.href path - ] - [ H.text text ] + _ -> + [ viewLink "About" Route.Path.Home_ -- TODO: or add about page, or delete the link + , H.a + [ A.class "px-4 py-2 border border-gray-300 rounded-md text-black hover:bg-gray-50 transition-colors" + , Route.Path.href Route.Path.Auth + ] + [ H.text "Sign In/Up" ] + ]
@@ -4,6 +4,8 @@ import Api
import Api.Auth import Auth.User import Components.Error +import Components.Form +import Components.Utils import Data.Credentials exposing (Credentials) import Effect exposing (Effect) import Html as H exposing (Html)@@ -142,6 +144,7 @@ ApiSignInResponded (Ok credentials) ->
( { model | isSubmittingForm = False }, Effect.signin credentials ) ApiSignInResponded (Err error) -> + -- TODO: check if error is Unauthorized and prompt use to activate account ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none ) ApiSignUpResponded (Ok ()) ->@@ -225,14 +228,10 @@ timeLeftSeconds =
case ( now, lastClicked ) of ( Just now_, Just last ) -> let - remainingMs = - 30 * 1000 - (Time.posixToMillis now_ - Time.posixToMillis last) + elapsedMs = + Time.posixToMillis now_ - Time.posixToMillis last in - if remainingMs > 0 then - remainingMs // 1000 - - else - 0 + max 0 ((30 * 1000 - elapsedMs) // 1000) _ -> 0@@ -250,17 +249,11 @@ , E.onClick UserClickedResendActivationEmail
, A.disabled (not canClick) ] [ H.text "Resend verification email" ] - , if canClick then - H.text "" - - else - H.p [ A.class "text-gray-600 text-xs mt-2" ] - [ H.text - ("You can request a new verification email in " - ++ String.fromInt timeLeftSeconds - ++ " seconds." - ) - ] + , Components.Utils.viewIf (not canClick) + (H.p + [ A.class "text-gray-600 text-xs mt-2" ] + [ H.text ("You can request a new verification email in " ++ String.fromInt timeLeftSeconds ++ " seconds.") ] + ) ]@@ -284,11 +277,11 @@
viewChangeVariant : Variant -> Html Msg viewChangeVariant variant = let - base = - "flex-1 px-4 py-2 rounded-md font-medium transition-colors" - - buttonClasses : Bool -> String buttonClasses active = + let + base = + "flex-1 px-4 py-2 rounded-md font-medium transition-colors" + in if active then base ++ " bg-black text-white"@@ -336,22 +329,18 @@
viewFormInput : { field : Field, value : String } -> Html Msg viewFormInput opts = - H.div [ A.class "space-y-2" ] - [ H.label - [ A.class "block text-sm font-medium text-gray-700" ] - [ H.text (fromFieldToLabel opts.field) ] - , H.div [] - [ H.input - [ 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" - , A.type_ (fromFieldToInputType opts.field) - , A.value opts.value - , A.placeholder (fromFieldToLabel opts.field) - , A.required True - , E.onInput (UserUpdatedInput opts.field) - ] - [] - ] - ] + Components.Form.input + { id = fromFieldToInputType opts.field + , field = opts.field + , label = fromFieldToLabel opts.field + , type_ = fromFieldToInputType opts.field + , value = opts.value + , placeholder = fromFieldToLabel opts.field + , required = True + , onInput = UserUpdatedInput opts.field + , helpText = Nothing + , prefix = Nothing + } viewForgotPassword : Html Msg
@@ -3,6 +3,8 @@
import Api import Api.Note import Components.Error +import Components.Form +import Components.Utils import Data.Note as Note import Effect exposing (Effect) import ExpirationOptions exposing (expirationOptions)@@ -209,12 +211,7 @@ [ H.div [ A.class "w-full max-w-4xl mx-auto" ]
[ H.div [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ] [ viewHeader model.pageVariant , H.div [ A.class "p-6 space-y-6" ] - [ case model.apiError of - Just error -> - Components.Error.error (Api.errorMessage error) - - Nothing -> - H.text "" + [ Components.Utils.viewMaybe model.apiError (\e -> Components.Error.error (Api.errorMessage e)) , case model.pageVariant of CreateNote -> viewCreateNoteForm model shared.appURL@@ -257,23 +254,31 @@ [ E.onSubmit UserClickedSubmit
, A.class "space-y-6" ] [ viewTextarea - , viewFormInput - { field = Slug + , Components.Form.input + { id = "slug" + , field = Slug , label = "Custom URL Slug (optional)" , placeholder = "my-unique-slug" , type_ = "text" - , help = "Leave empty to generate a random slug" + , helpText = Just "Leave empty to generate a random slug" , prefix = Just (secretUrl appUrl "") + , onInput = UserUpdatedInput Slug + , required = False + , value = Maybe.withDefault "" model.slug } , H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ] [ H.div [ A.class "space-y-6" ] - [ viewFormInput - { field = Password + [ Components.Form.input + { id = "password" + , field = Password , label = "Password Protection (optional)" - , type_ = "text" + , type_ = "password" , placeholder = "Enter password to protect this paste" - , help = "Viewers will need this password to access the paste" + , helpText = Just "Viewers will need this password to access the paste" , prefix = Nothing + , onInput = UserUpdatedInput Password + , required = False + , value = Maybe.withDefault "" model.password } ] , H.div [ A.class "space-y-6" ]@@ -302,34 +307,6 @@ , A.rows 20
, 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" ] [] - ] - - -viewFormInput : { field : Field, label : String, placeholder : String, type_ : String, prefix : Maybe String, help : String } -> Html Msg -viewFormInput options = - H.div [ A.class "space-y-2" ] - [ H.label - [ A.for (fromFieldToName options.field) - , A.class "block text-sm font-medium text-gray-700 mb-2" - ] - [ H.text options.label ] - , H.div [ A.class "flex items-center" ] - [ case options.prefix of - Just prefix -> - H.span [ A.class "text-gray-500 text-md mr-2 whitespace-nowrap" ] [ H.text prefix ] - - Nothing -> - H.text "" - , H.input - [ E.onInput (UserUpdatedInput options.field) - , A.id (fromFieldToName options.field) - , A.type_ options.type_ - , A.placeholder options.placeholder - , 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" - ] - [] - ] - , H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text options.help ] ]
@@ -3,7 +3,7 @@
import Api import Api.Note import Components.Error -import Components.Note +import Components.Utils import Data.Note exposing (Metadata, Note) import Effect exposing (Effect) import Html as H exposing (Html)@@ -199,30 +199,23 @@
viewShowNoteHeader : Zone -> String -> Note -> Html Msg viewShowNoteHeader zone slug note = H.div [] - [ if note.burnBeforeExpiration then - H.div [ A.class "bg-orange-50 border-b border-orange-200 p-4" ] + [ Components.Utils.viewIf note.burnBeforeExpiration + (H.div [ A.class "bg-orange-50 border-b border-orange-200 p-4" ] [ H.div [ A.class "flex items-center gap-3" ] [ H.div [ A.class "w-6 h-6 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0" ] - [ Components.Note.warningSvg ] + [ Components.Utils.loadSvg { path = "warning.svg", class = "w-4 h-4 text-orange-600" } ] , H.p [ A.class "text-orange-800 text-sm font-medium" ] [ H.text "This note was destroyed. If you need to keep it, copy it before closing this window." ] ] ] - - else - H.text "" + ) , H.div [ A.class "p-6 pb-4 border-b border-gray-200" ] [ H.div [ A.class "flex justify-between items-start" ] [ H.div [] [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text ("Note: " ++ slug) ] , H.div [ A.class "text-sm text-gray-500 mt-2 space-y-1" ] [ H.p [] [ H.text ("Created: " ++ T.toString zone note.createdAt) ] - , case note.expiresAt of - Just expiresAt -> - H.p [] [ H.text ("Expires at: " ++ T.toString zone expiresAt) ] - - Nothing -> - H.text "" + , Components.Utils.viewMaybe note.expiresAt (\n -> H.p [] [ H.text ("Expires at: " ++ T.toString zone n) ]) ] ] , H.div [ A.class "flex gap-2" ]@@ -246,7 +239,7 @@ viewNoteNotFound slug =
H.div [ A.class "p-6" ] [ H.div [ A.class "text-center py-12" ] [ H.div [ A.class "w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4" ] - [ Components.Note.noteNotFoundSvg ] + [ Components.Utils.loadSvg { path = "note-not-found.svg", class = "w-8 h-8 text-red-500" } ] , H.h2 [ A.class "text-xl font-semibold text-gray-900 mb-2" ] [ H.text ("Note " ++ slug ++ " Not Found") ] , H.div [ A.class "text-gray-600 mb-6 space-y-2" ]@@ -265,13 +258,7 @@ ]
] -viewOpenNote : - { slug : String - , hasPassword : Bool - , isLoading : Bool - , password : Maybe String - } - -> Html Msg +viewOpenNote : { slug : String, hasPassword : Bool, isLoading : Bool, password : Maybe String } -> Html Msg viewOpenNote opts = let isDisabled =@@ -295,7 +282,7 @@ H.div [ A.class "p-6" ]
[ H.div [ A.class "text-center py-12" ] [ H.div [ A.class "mb-6" ] [ H.div [ A.class "w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4" ] - [ Components.Note.noteIconSvg ] + [ Components.Utils.loadSvg { path = "note-icon.svg", class = "w-8 h-8 text-gray-400" } ] , H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-2" ] [ H.text opts.slug ] , H.p [ A.class "text-gray-600 mb-6" ] [ H.text "You're about read and destroy the note." ] ]@@ -303,8 +290,8 @@ , H.form
[ E.onSubmit UserClickedViewNote , A.class "max-w-sm mx-auto space-y-4" ] - [ if opts.hasPassword then - H.div + [ Components.Utils.viewIf opts.hasPassword + (H.div [ A.class "space-y-2" ] [ H.label [ A.class "block text-sm font-medium text-gray-700 text-left" ]@@ -315,9 +302,7 @@ , 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"
] [] ] - - else - H.text "" + ) , H.button [ A.class buttonData.class , A.type_ "submit"
@@ -0,0 +1,13 @@
+<svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" +> + <path + 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" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> +</svg>
@@ -0,0 +1,19 @@
+<svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" +> + <path + 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" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> + <path + d="M6 18L18 6M6 6l12 12" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> +</svg>
@@ -0,0 +1,13 @@
+<svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" +> + <path + 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" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> +</svg>