@@ -22,6 +22,7 @@ - docker compose up -d --build --remove-orphans core mailer
lint: - golangci-lint run + - task: frontend:lint docker:up: - docker compose up -d --build --remove-orphans@@ -36,6 +37,7 @@
test: - task: test:unit - task: test:e2e + - task: frontend:test test:unit: - '{{.gotest}} --count=1 -v --short ./...'
@@ -20,3 +20,7 @@
dev: desc: runs elm-land dev server cmd: bunx elm-land server + + build: + desc: runs elm-land build + cmd: bunx elm-land build
@@ -4,6 +4,12 @@ import Http
import Json.Decode +type Response value + = Loading + | Success value + | Failure Error + + type Error = HttpError { message : String@@ -13,12 +19,6 @@ | JsonDecodeError
{ message : String , reason : Json.Decode.Error } - - -type Response value - = Loading - | Success value - | Failure Error errorMessage : Error -> String
@@ -16,7 +16,6 @@ }
-> Effect msg signin options = let - body : Encode.Value body = Encode.object [ ( "email", Encode.string options.email )@@ -40,7 +39,6 @@ }
-> Effect msg signup options = let - body : Encode.Value body = Encode.object [ ( "email", Encode.string options.email )@@ -56,20 +54,12 @@ , decoder = Decode.succeed ()
} -refreshToken : - { onResponse : Result Api.Error Credentials -> msg - , refreshToken : String - } - -> Effect msg +refreshToken : { onResponse : Result Api.Error Credentials -> msg, refreshToken : String } -> Effect msg refreshToken options = - let - body = - Encode.object [ ( "refresh_token", Encode.string options.refreshToken ) ] - in Effect.sendApiRequest { endpoint = "/api/v1/auth/refresh-tokens" , method = "POST" - , body = Http.jsonBody body + , body = Encode.object [ ( "refresh_token", Encode.string options.refreshToken ) ] |> Http.jsonBody , onResponse = options.onResponse , decoder = Credentials.decode }@@ -99,14 +89,10 @@
resendVerificationEmail : { onResponse : Result Api.Error () -> msg, email : String } -> Effect msg resendVerificationEmail options = - let - body = - Encode.object [ ( "email", Encode.string options.email ) ] - in Effect.sendApiRequest { endpoint = "/api/v1/auth/resend-verification-email" , method = "POST" - , body = Http.jsonBody body + , body = Encode.object [ ( "email", Encode.string options.email ) ] |> Http.jsonBody , onResponse = options.onResponse , decoder = Decode.succeed () }
@@ -20,8 +20,8 @@ }
-> Effect msg create options = let - encodeMaybe : Maybe a -> String -> (a -> E.Value) -> ( String, E.Value ) - encodeMaybe maybe field value = + encodeMaybe : String -> (a -> E.Value) -> Maybe a -> ( String, E.Value ) + encodeMaybe field value maybe = case maybe of Just data -> ( field, value data )@@ -32,8 +32,8 @@
body = E.object [ ( "content", E.string options.content ) - , encodeMaybe options.slug "slug" E.string - , encodeMaybe options.password "password" E.string + , encodeMaybe "slug" E.string options.slug + , encodeMaybe "password" E.string options.password , ( "burn_before_expiration", E.bool options.burnBeforeExpiration ) , if options.expiresAt == Time.millisToPosix 0 then ( "expires_at", E.null )
@@ -13,8 +13,6 @@ type alias User =
Auth.User.User -{-| Called before an auth-only page is loaded. --} onPageLoad : Shared.Model -> Route () -> Auth.Action.Action User onPageLoad shared _ = case shared.user of@@ -32,8 +30,6 @@ Auth.User.SignedIn credentials ->
Auth.Action.loadPageWithUser credentials -{-| Renders whenever `Auth.Action.loadCustomPage` is returned from `onPageLoad`. --} viewCustomPage : Shared.Model -> Route () -> View Never viewCustomPage _ _ = View.fromString "Loading..."
@@ -1,12 +1,16 @@
-module Components.Form exposing (input) +module Components.Form exposing (ButtonStyle(..), CanBeClicked, button, input, submitButton) import Html as H exposing (Html) import Html.Attributes as A import Html.Events as E + +-- INPUT + + input : - -- TODO: add `error : Maybe String`, to input to show that field is not correct and message + -- TODO: add `error : Maybe String`, to show that field is not correct and message { id : String , field : field , label : String@@ -59,3 +63,81 @@
Nothing -> H.text "" ] + + + +-- BUTTON + + +type alias CanBeClicked = + Bool + + +type ButtonStyle + = Primary CanBeClicked + | Secondary CanBeClicked + | SecondaryDisabled CanBeClicked + | SecondaryDanger + + +button : { text : String, disabled : Bool, onClick : msg, style : ButtonStyle } -> Html msg +button opts = + H.button + [ A.type_ "button" + , E.onClick opts.onClick + , A.class (buttonStyleToClass opts.style "") + , A.disabled opts.disabled + ] + [ H.text opts.text ] + + +submitButton : { text : String, disabled : Bool, class : String, style : ButtonStyle } -> Html msg +submitButton opts = + H.button + [ A.type_ "submit" + , A.class (buttonStyleToClass opts.style opts.class) + , A.disabled opts.disabled + ] + [ H.text opts.text ] + + +buttonStyleToClass : ButtonStyle -> String -> String +buttonStyleToClass style appendClasses = + case style of + Primary canBeClicked -> + getButtonClasses canBeClicked + appendClasses + "px-6 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors" + "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" + + SecondaryDanger -> + "text-gray-600 hover:text-red-600 transition-colors" + + Secondary canBeClicked -> + getButtonClasses canBeClicked + appendClasses + "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" + "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" + + SecondaryDisabled canBeClicked -> + getButtonClasses canBeClicked + appendClasses + "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" + "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" + + +getButtonClasses : Bool -> String -> String -> String -> String +getButtonClasses cond extend whenTrue whenFalse = + let + cls = + if String.isEmpty extend then + "" + + else + " " ++ extend + in + if cond then + whenTrue ++ cls + + else + whenFalse ++ cls
@@ -0,0 +1,27 @@
+module Components.Icon exposing (IconType(..), view) + +import Html as H exposing (Html) +import Html.Attributes as A + + +type IconType + = NoteIcon + | NotFound + | Warning + + +view : IconType -> String -> Html msg +view t cls = + let + getHtml img = + H.img [ A.src ("/static/" ++ img ++ ".svg"), A.class cls ] [] + in + case t of + NoteIcon -> + getHtml "note-icon" + + NotFound -> + getHtml "note-not-found" + + Warning -> + getHtml "warning"
@@ -1,4 +1,4 @@
-module Components.Utils exposing (loadSvg, viewIf, viewMaybe) +module Components.Utils exposing (commonContainer, viewIf, viewMaybe) import Html as H exposing (Html) import Html.Attributes as A@@ -23,6 +23,7 @@ Nothing ->
H.text "" -loadSvg : { path : String, class : String } -> Html msg -loadSvg { path, class } = - H.img [ A.src ("/static/" ++ path), A.class class ] [] +commonContainer : List (Html msg) -> Html msg +commonContainer child = + H.div [ A.class "py-8 w-full max-w-4xl mx-auto " ] + [ H.div [ A.class "rounded-lg border border-gray-200 shadow-sm" ] child ]
@@ -1,18 +1,16 @@
module Effect exposing - ( Effect, none, batch, sendCmd, sendMsg + ( Effect, none, batch, map, toCmd, sendCmd, sendMsg , pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back , sendApiRequest, sendToClipboard , signin, logout, refreshTokens, saveUser, clearUser - , map, toCmd ) {-| -@docs Effect, none, batch, sendCmd, sendMsg +@docs Effect, none, batch, map, toCmd, sendCmd, sendMsg @docs pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back @docs sendApiRequest, sendToClipboard @docs signin, logout, refreshTokens, saveUser, clearUser -@docs map, toCmd -}@@ -355,17 +353,16 @@ Err err ->
Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) Http.BadStatus_ { statusCode } body -> - case body of - "" -> - Err (Api.HttpError { message = "Unexpected empty response", reason = Http.BadStatus statusCode }) + if String.isEmpty body then + Err (Api.HttpError { message = "Unexpected empty response", reason = Http.BadStatus statusCode }) - _ -> - case Json.Decode.decodeString Data.Error.decode body of - Ok err -> - Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode }) + else + case Json.Decode.decodeString Data.Error.decode body of + Ok err -> + Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode }) - Err err -> - Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) + Err err -> + Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) Http.BadUrl_ url -> Err (Api.HttpError { message = "Unexpected URL format", reason = Http.BadUrl url })
@@ -1,4 +1,4 @@
-module ExpirationOptions exposing (expirationOptions) +module Constants exposing (expirationOptions) expirationOptions : List { text : String, value : Int }
@@ -1,10 +1,10 @@
module Layouts.Header exposing (Model, Msg, Props, layout) import Auth.User +import Components.Form import Effect exposing (Effect) import Html as H exposing (Html) import Html.Attributes as A -import Html.Events as E import Layout exposing (Layout) import Route exposing (Route) import Route.Path@@ -101,16 +101,16 @@ in
case user of Auth.User.SignedIn _ -> [ 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" ] + , Components.Form.button + { text = "Logout" + , onClick = UserClickedLogout + , style = Components.Form.SecondaryDanger + , disabled = False + } ] _ -> - [ viewLink "About" Route.Path.Home_ -- TODO: or add about page, or delete the link - , H.a + [ 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 ]
@@ -1,4 +1,4 @@
-module Pages.Auth exposing (Banner, Model, Msg, Variant, page) +module Pages.Auth exposing (Banner, FormVariant, Model, Msg, page) import Api import Api.Auth@@ -41,8 +41,8 @@ { email : String
, password : String , passwordAgain : String , isSubmittingForm : Bool - , formVariant : Variant , banner : Banner + , formVariant : FormVariant , lastClicked : Maybe Posix , now : Maybe Posix }@@ -84,7 +84,7 @@
type Msg = Tick Posix | UserUpdatedInput Field String - | UserChangedFormVariant Variant + | UserChangedFormVariant FormVariant | UserClickedSubmit | UserClickedResendActivationEmail | ApiSignInResponded (Result Api.Error Credentials)@@ -104,7 +104,7 @@ type alias ResetPasswordToken =
String -type Variant +type FormVariant = SignIn | SignUp | ForgotPassword@@ -224,7 +224,7 @@ [ H.div [ A.class "min-h-screen flex items-center justify-center bg-gray-50 p-4" ]
[ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ] -- TODO: add oauth buttons [ viewBanner model - , viewHeader model.formVariant + , viewBoxHeader model.formVariant , H.div [ A.class "px-6 pb-6 space-y-4" ] [ viewChangeVariant model.formVariant , H.div [ A.class "border-t border-gray-200" ] []@@ -255,16 +255,6 @@
viewVerificationBanner : Maybe Posix -> Maybe Posix -> Html Msg viewVerificationBanner now lastClicked = let - buttonClassesBase = - "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" - - buttonClasses active = - if active then - buttonClassesBase ++ " border border-gray-300 text-gray-700 hover:bg-gray-50" - - else - buttonClassesBase ++ " border border-gray-300 text-gray-400 cursor-not-allowed" - timeLeftSeconds = case ( now, lastClicked ) of ( Just now_, Just last ) ->@@ -284,22 +274,21 @@ in
Components.Box.successBox [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text "Check your email!" ] , 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." ] - , H.button - [ A.class (buttonClasses canClick) - , E.onClick UserClickedResendActivationEmail - , A.disabled (not canClick) - ] - [ H.text "Resend verification email" ] + , Components.Form.button + { text = "Resend verification email" + , onClick = UserClickedResendActivationEmail + , disabled = not canClick + , style = Components.Form.SecondaryDisabled canClick + } , Components.Utils.viewIf (not canClick) - (H.p - [ A.class "text-gray-600 text-xs mt-2" ] + (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.") ] ) ] -viewHeader : Variant -> Html Msg -viewHeader variant = +viewBoxHeader : FormVariant -> Html Msg +viewBoxHeader variant = let ( title, description ) = case variant of@@ -321,33 +310,21 @@ , H.p [ A.class "text-center text-gray-600 text-sm" ] [ H.text description ]
] -viewChangeVariant : Variant -> Html Msg +viewChangeVariant : FormVariant -> Html Msg viewChangeVariant variant = - let - 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" - - else - base ++ " bg-white text-black border border-gray-300 hover:bg-gray-50" - in - H.div [ A.class "flex gap-2" ] - [ H.button - [ A.class (buttonClasses (variant == SignIn)) - , A.disabled (variant == SignIn) - , E.onClick (UserChangedFormVariant SignIn) - ] - [ H.text "Sign In" ] - , H.button - [ A.class (buttonClasses (variant == SignUp)) - , A.disabled (variant == SignUp) - , E.onClick (UserChangedFormVariant SignUp) - ] - [ H.text "Sign Up" ] + H.div [ A.class "flex [&>*]:flex-1 gap-2" ] + [ Components.Form.button + { text = "Sign In" + , onClick = UserChangedFormVariant SignIn + , style = Components.Form.Primary (variant == SignIn) + , disabled = variant == SignIn + } + , Components.Form.button + { text = "Sign Up" + , disabled = variant == SignUp + , style = Components.Form.Primary (variant == SignUp) + , onClick = UserChangedFormVariant SignUp + } ]@@ -389,12 +366,12 @@
viewFormInput : { field : Field, value : String } -> Html Msg viewFormInput opts = Components.Form.input - { id = fromFieldToInputType opts.field + { id = (fromFieldToFieldInfo opts.field).label , field = opts.field - , label = fromFieldToLabel opts.field - , type_ = fromFieldToInputType opts.field + , label = (fromFieldToFieldInfo opts.field).label + , type_ = (fromFieldToFieldInfo opts.field).type_ , value = opts.value - , placeholder = fromFieldToLabel opts.field + , placeholder = (fromFieldToFieldInfo opts.field).label , required = True , onInput = UserUpdatedInput opts.field , helpText = Nothing@@ -416,18 +393,12 @@
viewSubmitButton : Model -> Html Msg viewSubmitButton model = - H.button - [ A.type_ "submit" - , A.disabled (isFormDisabled model) - , A.class - (if isFormDisabled model then - "w-full px-4 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors" - - else - "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" - ) - ] - [ H.text (fromVariantToLabel model.formVariant) ] + Components.Form.submitButton + { class = "w-full" + , text = fromVariantToLabel model.formVariant + , style = Components.Form.Primary (isFormDisabled model) + , disabled = isFormDisabled model + } isFormDisabled : Model -> Bool@@ -455,7 +426,7 @@ || String.isEmpty model.passwordAgain
|| (model.password /= model.passwordAgain) -fromVariantToLabel : Variant -> String +fromVariantToLabel : FormVariant -> String fromVariantToLabel variant = case variant of SignIn ->@@ -471,27 +442,14 @@ SetNewPassword _ ->
"Set new password" -fromFieldToLabel : Field -> String -fromFieldToLabel field = - case field of - Email -> - "Email address" - - Password -> - "Password" - - PasswordAgain -> - "Confirm password" - - -fromFieldToInputType : Field -> String -fromFieldToInputType field = +fromFieldToFieldInfo : Field -> { label : String, type_ : String } +fromFieldToFieldInfo field = case field of Email -> - "email" + { label = "Email address", type_ = "email" } Password -> - "password" + { label = "Password", type_ = "password" } PasswordAgain -> - "password" + { label = "Confirm password", type_ = "password" }
@@ -5,9 +5,9 @@ import Api.Note
import Components.Box import Components.Form import Components.Utils +import Constants exposing (expirationOptions) import Data.Note as Note import Effect exposing (Effect) -import ExpirationOptions exposing (expirationOptions) import Html as H exposing (Html) import Html.Attributes as A import Html.Events as E@@ -49,10 +49,6 @@ , now : Maybe Posix
} - --- TODO: store slug as Slug type - - type PageVariant = CreateNote | NoteCreated String@@ -79,8 +75,8 @@ -- UPDATE
type Msg - = CopyButtonReset - | Tick Posix + = Tick Posix + | CopyButtonReset | UserUpdatedInput Field String | UserClickedCheckbox Bool | UserClickedSubmit@@ -149,14 +145,14 @@ UserUpdatedInput Content content ->
( { model | content = content }, Effect.none ) UserUpdatedInput Slug slug -> - if slug == "" then + if String.isEmpty slug then ( { model | slug = Nothing }, Effect.none ) else ( { model | slug = Just slug }, Effect.none ) UserUpdatedInput Password password -> - if password == "" then + if String.isEmpty password then ( { model | password = Nothing }, Effect.none ) else@@ -177,10 +173,6 @@ ( { model | pageVariant = NoteCreated response.slug, slug = Just response.slug, apiError = Nothing }, Effect.none )
ApiCreateNoteResponded (Err error) -> ( { model | apiError = Just error }, Effect.none ) - - - --- SUBSCRIPTIONS subscriptions : Model -> Sub Msg@@ -204,30 +196,31 @@
view : Shared.Model -> Model -> View Msg view shared model = + let + appUrl = + secretUrl shared.appURL + in { title = "Onasty" , body = - [ H.div [ A.class "py-8 px-4 " ] - [ 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" ] - [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) - , case model.pageVariant of - CreateNote -> - viewCreateNoteForm model shared.appURL + [ Components.Utils.commonContainer + [ viewHeader model.pageVariant model.apiError + , H.div [ A.class "p-6 space-y-6" ] + [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) + , case model.pageVariant of + CreateNote -> + viewCreateNoteForm model appUrl - NoteCreated slug -> - viewNoteCreated model.userClickedCopyLink shared.appURL slug - ] - ] + NoteCreated slug -> + Components.Utils.viewIf (model.apiError == Nothing) + (viewNoteCreated model.userClickedCopyLink appUrl slug) ] ] ] } -viewHeader : PageVariant -> Html Msg -viewHeader pageVariant = +viewHeader : PageVariant -> Maybe Api.Error -> Html Msg +viewHeader pageVariant apiError = H.div [ A.class "p-6 pb-4 border-b border-gray-200" ] [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text@@ -236,7 +229,11 @@ CreateNote ->
"Create a new note" NoteCreated _ -> - "Paste Created Successfully!" + if apiError == Nothing then + "Paste Created Successfully!" + + else + "Could not create the note." ) ] ]@@ -244,10 +241,10 @@
-- VIEW CREATE NOTE --- TODO: validate form +-- TODO: validate the form -viewCreateNoteForm : Model -> String -> Html Msg +viewCreateNoteForm : Model -> (String -> String) -> Html Msg viewCreateNoteForm model appUrl = H.form [ E.onSubmit UserClickedSubmit@@ -261,7 +258,7 @@ , label = "Custom URL Slug (optional)"
, placeholder = "my-unique-slug" , type_ = "text" , helpText = Just "Leave empty to generate a random slug" - , prefix = Just (secretUrl appUrl "") + , prefix = Just (appUrl "") , onInput = UserUpdatedInput Slug , required = False , value = Maybe.withDefault "" model.slug@@ -286,7 +283,14 @@ [ viewExpirationTimeSelector
, viewBurnBeforeExpirationCheckbox ] ] - , H.div [ A.class "flex justify-end" ] [ viewSubmitButton model ] + , H.div [ A.class "flex justify-end" ] + [ Components.Form.submitButton + { text = "Create note" + , style = Components.Form.Primary (isFormDisabled model) + , disabled = False + , class = "" + } + ] ]@@ -349,36 +353,11 @@ ]
] -viewSubmitButton : Model -> Html Msg -viewSubmitButton model = - H.button - [ A.type_ "submit" - , A.disabled (isFormDisabled model) - , A.class - (if isFormDisabled model then - "px-6 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors" - - else - "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" - ) - ] - [ H.text "Create note" ] - - isFormDisabled : Model -> Bool isFormDisabled model = String.isEmpty model.content -viewCreateNewNoteButton : Html Msg -viewCreateNewNoteButton = - H.button - [ 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" - , E.onClick UserClickedCreateNewNote - ] - [ H.text "Create New Paste" ] - - fromFieldToName : Field -> String fromFieldToName field = case field of@@ -399,43 +378,30 @@
-- VIEW NOTE CREATED -viewNoteCreated : Bool -> String -> String -> Html Msg +viewNoteCreated : Bool -> (String -> String) -> String -> Html Msg viewNoteCreated userClickedCopyLink appUrl slug = H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-6" ] - [ H.div [ A.class "bg-white border border-green-300 rounded-md p-4 mb-4" ] - [ H.p [ A.class "text-sm text-gray-600 mb-2" ] - [ H.text "Your paste is available at:" ] - , H.p [ A.class "font-mono text-sm text-gray-800 break-all" ] - [ H.text (secretUrl appUrl slug) ] + [ H.div [ A.class "border border-green-300 rounded-md p-4 mb-4" ] + [ H.p [ A.class "text-sm text-gray-600 mb-2" ] [ H.text "Your paste is available at:" ] + , H.p [ A.class "font-mono text-sm text-gray-800" ] [ H.text (appUrl slug) ] ] , H.div [ A.class "flex gap-3" ] - [ viewCopyLinkButton userClickedCopyLink - , viewCreateNewNoteButton + [ Components.Form.button + { text = "Create New Paste" + , onClick = UserClickedCreateNewNote + , style = Components.Form.Primary False + , disabled = False + } + , Components.Form.button + { style = Components.Form.Secondary userClickedCopyLink + , onClick = UserClickedCopyLink + , disabled = userClickedCopyLink + , text = + if userClickedCopyLink then + "Copied!" + + else + "Copy URL" + } ] ] - - -viewCopyLinkButton : Bool -> Html Msg -viewCopyLinkButton isClicked = - let - base = - "px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors" - in - H.button - [ A.class - (if isClicked then - base ++ " bg-green-100 border-green-300 text-green-700" - - else - base ++ " border-gray-300 text-gray-700 hover:bg-gray-50" - ) - , E.onClick UserClickedCopyLink - ] - [ H.text - (if isClicked then - "Copied!" - - else - "Copy URL" - ) - ]
@@ -3,6 +3,8 @@
import Api import Api.Note import Components.Box +import Components.Form +import Components.Icon import Components.Utils import Data.Note exposing (Metadata, Note) import Effect exposing (Effect)@@ -109,10 +111,6 @@ ApiGetMetadataResponded (Err error) ->
( { model | page = NotFound, metadata = Api.Failure error }, Effect.none ) - --- SUBSCRIPTIONS - - subscriptions : Model -> Sub Msg subscriptions _ = Sub.none@@ -126,29 +124,25 @@ view : Shared.Model -> Model -> View Msg
view shared model = { title = "View note" , body = - [ 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" ] - (case model.metadata of - Api.Success metadata -> - viewPage shared.timeZone model.slug model.page metadata model.password + [ Components.Utils.commonContainer + (case model.metadata of + Api.Success metadata -> + viewPage shared.timeZone model.slug model.page metadata model.password - Api.Loading -> - [ viewHeader { title = "View note", subtitle = "Loading note metadata..." } - , viewOpenNote { slug = model.slug, hasPassword = False, password = Nothing, isLoading = True } - ] + Api.Loading -> + [ viewHeader { title = "View note", subtitle = "Loading note metadata..." } + , viewOpenNote { slug = model.slug, hasPassword = False, password = Nothing, isLoading = True } + ] - Api.Failure error -> - [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } - , if Api.is404 error then - viewNoteNotFound model.slug + Api.Failure error -> + [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } + , if Api.is404 error then + viewNoteNotFound - else - Components.Box.error (Api.errorMessage error) - ] - ) - ] + else + Components.Box.error (Api.errorMessage error) + ] + ) ] }@@ -175,11 +169,11 @@ ]
Api.Failure _ -> [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } - , viewNoteNotFound slug + , viewNoteNotFound ] NotFound -> - [ viewNoteNotFound slug ] + [ viewNoteNotFound ]@@ -203,7 +197,7 @@ [ 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.Utils.loadSvg { path = "warning.svg", class = "w-4 h-4 text-orange-600" } ] + [ Components.Icon.view Components.Icon.Warning "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." ] ]@@ -219,11 +213,12 @@ , Components.Utils.viewMaybe note.expiresAt (\n -> H.p [] [ H.text ("Expires at: " ++ T.toString zone n) ])
] ] , H.div [ A.class "flex gap-2" ] - [ H.button - [ E.onClick UserClickedCopyContent - , 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" - ] - [ H.text "Copy Content" ] + [ Components.Form.button + { text = "Copy Content" + , style = Components.Form.SecondaryDisabled False + , onClick = UserClickedCopyContent + , disabled = False + } ] ] ]@@ -234,26 +229,14 @@
-- NOTE -viewNoteNotFound : String -> Html msg -viewNoteNotFound slug = +viewNoteNotFound : Html msg +viewNoteNotFound = 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.Utils.loadSvg { path = "note-not-found.svg", class = "w-8 h-8 text-red-500" } ] + [ Components.Icon.view Components.Icon.NotFound "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" ] - [ H.p [] - [ H.span [ A.class "font-bold" ] [ H.text "This note may have:" ] - , H.ul [ A.class "text-sm space-y-1 list-disc list-inside text-left max-w-md mx-auto" ] - [ H.li [] [ H.text "Expired and been deleted" ] - , H.li [] [ H.text "Have different password" ] - , H.li [] [ H.text "Been deleted by the creator" ] - , H.li [] [ H.text "Been burned after reading" ] - , H.li [] [ H.text "Never existed or the URL is incorrect" ] - ] - ] - ] + [ H.text "Note not found" ] ] ]@@ -262,27 +245,13 @@ viewOpenNote : { slug : String, hasPassword : Bool, isLoading : Bool, password : Maybe String } -> Html Msg
viewOpenNote opts = let isDisabled = - opts.hasPassword && Maybe.withDefault "" opts.password == "" - - buttonData = - let - base = - "px-6 py-3 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors" - in - if opts.isLoading then - { text = "Loading Note...", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" } - - else if isDisabled then - { text = "View Note", class = base ++ " bg-gray-300 text-gray-500 cursor-not-allowed" } - - else - { text = "View Note", class = base ++ " bg-black text-white hover:bg-gray-800" } + (opts.hasPassword && Maybe.withDefault "" opts.password == "") || opts.isLoading in 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.Utils.loadSvg { path = "note-icon.svg", class = "w-8 h-8 text-gray-400" } ] + [ Components.Icon.view Components.Icon.NoteIcon "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." ] ]@@ -291,11 +260,8 @@ [ E.onSubmit UserClickedViewNote
, A.class "max-w-sm mx-auto space-y-4" ] [ 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" ] - [ H.text "Password" ] + (H.div [ A.class "space-y-2" ] + [ H.label [ A.class "block text-sm font-medium text-gray-700 text-left" ] [ H.text "Password" ] , H.input [ E.onInput UserUpdatedPassword , 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,12 +269,17 @@ ]
[] ] ) - , H.button - [ A.class buttonData.class - , A.type_ "submit" - , A.disabled isDisabled - ] - [ H.text buttonData.text ] + , Components.Form.submitButton + { text = + if opts.isLoading then + "Loading Note..." + + else + "View Note" + , style = Components.Form.Primary isDisabled + , disabled = isDisabled + , class = "py-3" + } ] ] ]
@@ -11,11 +11,7 @@ suite =
describe "Data.Error" [ test "decode" <| \_ -> - """ - { - "message": "some kind of an error" - } - """ + """ {"message": "some kind of an error"} """ |> Json.decodeString Data.Error.decode |> Expect.ok ]
@@ -11,18 +11,18 @@ suite =
describe "Data.Note" [ test "decodeCreateResponse" (\_ -> - "{\"slug\":\"the.note-slug\"}" + """ {"slug":"the.note-slug"} """ |> D.decodeString Data.Note.decodeCreateResponse |> Expect.ok ) , test "decodeMetadata" (\_ -> """ - { - "created_at": "2023-10-01T12:00:00Z", - "has_password": false - } - """ + { + "created_at": "2023-10-01T12:00:00Z", + "has_password": false + } + """ |> D.decodeString Data.Note.decodeMetadata |> Expect.ok )