all repos

onasty @ cd3e3ab

a one-time notes service
15 files changed, 573 insertions(+), 36 deletions(-)
web: create note page (#144)

* web: write html for create note

* web: api client for note creation

* web: create note

* web: add test for "create note response"

* web: init the types for variants of the page

* web: update the note creation api

* chore(env): update cors

* web: show user info about created note

* web: implement copy to clipboard

* web: make "copy link" button way too fancy

* web: pass frontend url into the elm app

* web: show user the actual frontend url to the secret

* web: make elm-review happy

* fixup! web: make elm-review happy

* fixup! web: show user the actual frontend url to the secret

* web: refactor the note creation form

* web: refactoring

* web: show the error message

* web: change the header content depended on page variant

* web: reuse error banner

* web: home, fix typography

* web: add password input for notes

* fixup! web: add password input for notes

* web: refactor

* web: add expiration time selector

* web: store static data in sep file

* web: update api

* web: change wording of checkbox label

* web: refactor model updates

* web: catch error when writing to clipboard
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-06-25 13:47:49 +0300
Parent: 67a69fb
M .env.example

@@ -1,11 +1,13 @@

-APP_ENV=debug APP_URL=http://localhost:8000 +FRONTEND_URL=http://localhost:1234 + +APP_ENV=debug PASSWORD_SALT=onasty NOTE_PASSWORD_SALT=secret HTTP_PORT=8000 -CORS_ALLOWED_ORIGINS=http://localhost:3000,http://onasty.localhost +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://onasty.localhost,http://localhost:1234 CORS_MAX_AGE=12h METRICS_ENABLED=true
M web/elm-land.json

@@ -8,7 +8,7 @@ "production": {

"debugger": false } }, - "env": [], + "env": ["FRONTEND_URL"], "html": { "attributes": { "html": {
M web/elm.json

@@ -14,12 +14,14 @@ "elm/http": "2.0.0",

"elm/json": "1.1.3", "elm/time": "1.0.0", "elm/url": "1.0.0", + "jweir/elm-iso8601": "7.0.1", "simonh1000/elm-jwt": "7.1.1" }, "indirect": { "danfishgold/base64-bytes": "1.1.0", "elm/bytes": "1.0.8", "elm/file": "1.0.5", + "elm/regex": "1.0.0", "elm/virtual-dom": "1.0.3" } },
A web/src/Api/Note.elm

@@ -0,0 +1,58 @@

+module Api.Note exposing (create) + +import Api +import Data.Note as Note exposing (CreateResponse) +import Effect exposing (Effect) +import Http +import ISO8601 +import Json.Encode as E +import Time exposing (Posix) + + +create : + { onResponse : Result Api.Error CreateResponse -> msg + , content : String + , slug : Maybe String + , password : Maybe String + , expiresAt : Posix + , burnBeforeExpiration : Bool + } + -> Effect msg +create options = + let + body : E.Value + body = + E.object + [ ( "content", E.string options.content ) + , case options.slug of + Just slug -> + ( "slug", E.string slug ) + + Nothing -> + ( "slug", E.null ) + , case options.password of + Just password -> + ( "password", E.string password ) + + Nothing -> + ( "password", E.null ) + , ( "burn_before_expiration", E.bool options.burnBeforeExpiration ) + , if options.expiresAt == Time.millisToPosix 0 then + ( "expires_at", E.null ) + + else + ( "expires_at" + , options.expiresAt + |> ISO8601.fromPosix + |> ISO8601.toString + |> E.string + ) + ] + in + Effect.sendApiRequest + { endpoint = "/api/v1/note" + , method = "POST" + , body = Http.jsonBody body + , onResponse = options.onResponse + , decoder = Note.decodeCreateResponse + }
A web/src/Components/Error.elm

@@ -0,0 +1,10 @@

+module Components.Error exposing (error) + +import Html as H exposing (Html) +import Html.Attributes as A + + +error : String -> Html msg +error errorMsg = + H.div [ A.class "bg-red-50 border border-red-200 rounded-md p-4" ] + [ H.p [ A.class "text-red-800 text-sm" ] [ H.text errorMsg ] ]
A web/src/Data/Note.elm

@@ -0,0 +1,13 @@

+module Data.Note exposing (CreateResponse, decodeCreateResponse) + +import Json.Decode as D exposing (Decoder) + + +type alias CreateResponse = + { slug : String } + + +decodeCreateResponse : Decoder CreateResponse +decodeCreateResponse = + D.map CreateResponse + (D.field "slug" D.string)
M web/src/Effect.elm

@@ -6,6 +6,7 @@ , pushRoute, replaceRoute

, pushRoutePath, replaceRoutePath , loadExternalUrl, back , sendApiRequest, refreshTokens + , sendToClipboard , signin, logout, saveUser, clearUser , map, toCmd )

@@ -22,6 +23,7 @@ @docs pushRoutePath, replaceRoutePath

@docs loadExternalUrl, back @docs sendApiRequest, refreshTokens +@docs sendToClipboard @docs signin, logout, saveUser, clearUser @docs map, toCmd

@@ -37,7 +39,7 @@ import Dict exposing (Dict)

import Http import Json.Decode import Json.Encode -import Ports exposing (sendToLocalStorage) +import Ports import Route import Route.Path import Shared.Model

@@ -59,6 +61,7 @@ | Back

-- SHARED | SendSharedMsg Shared.Msg.Msg | SendToLocalStorage { key : String, value : Json.Encode.Value } + | SendToClipboard String | SendApiRequest (HttpRequestDetails msg)

@@ -187,6 +190,11 @@ |> Json.Decode.map opts.onResponse

} +sendToClipboard : String -> Effect msg +sendToClipboard text = + SendToClipboard text + + refreshTokens : Effect msg refreshTokens = SendSharedMsg Shared.Msg.TriggerTokenRefresh

@@ -255,6 +263,9 @@

SendToLocalStorage options -> SendToLocalStorage options + SendToClipboard text -> + SendToClipboard text + SendApiRequest opts -> SendApiRequest { endpoint = opts.endpoint

@@ -305,7 +316,10 @@ Task.succeed sharedMsg

|> Task.perform options.fromSharedMsg SendToLocalStorage opts -> - sendToLocalStorage opts + Ports.sendToLocalStorage opts + + SendToClipboard text -> + Ports.sendToClipboard text SendApiRequest opts -> let
A web/src/ExpirationOptions.elm

@@ -0,0 +1,16 @@

+module ExpirationOptions exposing (ExpiresAt, expirationOptions) + + +type alias ExpiresAt = + { text : String, value : Int } + + +expirationOptions : List ExpiresAt +expirationOptions = + [ { text = "Never expires (default)", value = 0 } + , { text = "1 hour", value = 60 * 60 * 1000 } + , { text = "12 hours", value = 12 * 60 * 60 * 1000 } + , { text = "1 day", value = 24 * 60 * 60 * 1000 } + , { text = "3 days", value = 3 * 24 * 60 * 60 * 1000 } + , { text = "7 days", value = 7 * 24 * 60 * 60 * 1000 } + ]
M web/src/Pages/Auth.elm

@@ -3,6 +3,7 @@

import Api import Api.Auth import Auth.User +import Components.Error import Data.Credentials exposing (Credentials) import Effect exposing (Effect) import Html as H exposing (Html)

@@ -197,7 +198,7 @@ viewBanner : Model -> Html Msg

viewBanner model = case ( model.apiError, model.gotSignedUp ) of ( Just error, False ) -> - viewBannerError error + Components.Error.error (Api.errorMessage error) ( Nothing, True ) -> viewBannerSuccess model.now model.lastClicked

@@ -263,15 +264,6 @@ ++ String.fromInt timeLeftSeconds

++ " seconds." ) ] - ] - - -viewBannerError : Api.Error -> Html Msg -viewBannerError error = - H.div [ A.class "bg-red-50 border border-red-200 rounded-md p-4 mb-3" ] - [ H.p - [ A.class "text-red-800 text-sm" ] - [ H.text (Api.errorMessage error) ] ]

@@ -358,6 +350,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"

, A.type_ (fromFieldToInputType opts.field) , A.value opts.value , A.placeholder (fromFieldToLabel opts.field) + , A.required True , E.onInput (UserUpdatedInput opts.field) ] []
M web/src/Pages/Home_.elm

@@ -1,13 +1,21 @@

-module Pages.Home_ exposing (Model, Msg, page) +module Pages.Home_ exposing (Model, Msg, PageVariant, page) +import Api +import Api.Note +import Components.Error +import Data.Note as Note import Effect exposing (Effect) -import Html as H +import ExpirationOptions exposing (expirationOptions) +import Html as H exposing (Html) import Html.Attributes as A import Html.Events as E import Layouts import Page exposing (Page) +import Process import Route exposing (Route) import Shared +import Task +import Time exposing (Posix) import View exposing (View)

@@ -15,11 +23,11 @@ page : Shared.Model -> Route () -> Page Model Msg

page shared _ = Page.new { init = init shared - , update = update + , update = update shared , subscriptions = subscriptions , view = view shared } - |> Page.withLayout Layouts.Header + |> Page.withLayout (\_ -> Layouts.Header {})

@@ -27,12 +35,41 @@ -- INIT

type alias Model = - {} + { pageVariant : PageVariant + , content : String + , slug : Maybe String + , password : Maybe String + , expirationTime : Maybe Int + , dontBurnBeforeExpiration : Bool + , apiError : Maybe Api.Error + , userClickedCopyLink : Bool + , now : Maybe Posix + } + + + +-- TODO: store slug as Slug type + + +type PageVariant + = CreateNote + | NoteCreated String init : Shared.Model -> () -> ( Model, Effect Msg ) init _ () = - ( {}, Effect.none ) + ( { pageVariant = CreateNote + , content = "" + , slug = Nothing + , password = Nothing + , expirationTime = Nothing + , dontBurnBeforeExpiration = True + , userClickedCopyLink = False + , apiError = Nothing + , now = Nothing + } + , Effect.none + )

@@ -40,14 +77,105 @@ -- UPDATE

type Msg - = NoOp + = CopyButtonReset + | Tick Posix + | UserUpdatedInput Field String + | UserClickedCheckbox Bool + | UserClickedSubmit + | UserClickedCreateNewNote + | UserClickedCopyLink + | ApiCreateNoteResponded (Result Api.Error Note.CreateResponse) -update : Msg -> Model -> ( Model, Effect Msg ) -update msg model = +type Field + = Content + | Slug + | Password + | ExpirationTime + + +update : Shared.Model -> Msg -> Model -> ( Model, Effect Msg ) +update shared msg model = case msg of - NoOp -> - ( model, Effect.none ) + Tick now -> + ( { model | now = Just now }, Effect.none ) + + CopyButtonReset -> + ( { model | userClickedCopyLink = False }, Effect.none ) + + UserClickedSubmit -> + let + expiresAt : Posix + expiresAt = + case ( model.now, model.expirationTime ) of + ( Just now, Just expirationTime ) -> + Time.millisToPosix (Time.posixToMillis now + expirationTime) + + _ -> + Time.millisToPosix 0 + in + ( model + , Api.Note.create + { onResponse = ApiCreateNoteResponded + , content = model.content + , slug = model.slug + , password = model.password + , burnBeforeExpiration = not model.dontBurnBeforeExpiration + , expiresAt = expiresAt + } + ) + + UserClickedCreateNewNote -> + ( { model + | pageVariant = CreateNote + , content = "" + , slug = Nothing + , password = Nothing + , apiError = Nothing + } + , Effect.none + ) + + UserClickedCopyLink -> + ( { model | userClickedCopyLink = True } + , Effect.batch + [ Effect.sendCmd (Task.perform (\_ -> CopyButtonReset) (Process.sleep 2000)) + , Effect.sendToClipboard (secretUrl shared.appURL (Maybe.withDefault "" model.slug)) + ] + ) + + UserUpdatedInput Content content -> + ( { model | content = content }, Effect.none ) + + UserUpdatedInput Slug slug -> + if slug == "" then + ( { model | slug = Nothing }, Effect.none ) + + else + ( { model | slug = Just slug }, Effect.none ) + + UserUpdatedInput Password password -> + if password == "" then + ( { model | password = Nothing }, Effect.none ) + + else + ( { model | password = Just password }, Effect.none ) + + UserUpdatedInput ExpirationTime expirationTime -> + if expirationTime == "0" then + ( { model | expirationTime = Nothing }, Effect.none ) + + else + ( { model | expirationTime = String.toInt expirationTime }, Effect.none ) + + UserClickedCheckbox burnBeforeExpiration -> + ( { model | dontBurnBeforeExpiration = burnBeforeExpiration }, Effect.none ) + + ApiCreateNoteResponded (Ok response) -> + ( { model | pageVariant = NoteCreated response.slug, slug = Just response.slug, apiError = Nothing }, Effect.none ) + + ApiCreateNoteResponded (Err error) -> + ( { model | apiError = Just error }, Effect.none )

@@ -55,19 +183,284 @@ -- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg -subscriptions _ = - Sub.none +subscriptions model = + case model.expirationTime of + Just _ -> + Time.every 1000 Tick + + _ -> + Sub.none -- VIEW +secretUrl : String -> String -> String +secretUrl appUrl slug = + appUrl ++ "/secret/" ++ slug + + view : Shared.Model -> Model -> View Msg -view _ _ = - { title = "Homepage" +view shared model = + { title = "Onasty" , body = - [ H.div [ A.class "w-full max-w-6xl mx-auto" ] - [ H.p [ E.onClick NoOp ] [ H.text "Hello, world!" ] ] + [ 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" ] + [ case model.apiError of + Just error -> + Components.Error.error (Api.errorMessage error) + + Nothing -> + H.text "" + , case model.pageVariant of + CreateNote -> + viewCreateNoteForm model shared.appURL + + NoteCreated slug -> + viewNoteCreated model.userClickedCopyLink shared.appURL slug + ] + ] + ] + ] ] } + + +viewHeader : PageVariant -> Html Msg +viewHeader pageVariant = + 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 + (case pageVariant of + CreateNote -> + "Create a new note" + + NoteCreated _ -> + "Paste Created Successfully!" + ) + ] + ] + + + +-- VIEW CREATE NOTE +-- TODO: validate form + + +viewCreateNoteForm : Model -> String -> Html Msg +viewCreateNoteForm model appUrl = + H.form + [ E.onSubmit UserClickedSubmit + , A.class "space-y-6" + ] + [ viewTextarea + , viewFormInput + { field = Slug + , label = "Custom URL Slug (optional)" + , placeholder = "my-unique-slug" + , type_ = "text" + , help = "Leave empty to generate a random slug" + , prefix = Just (secretUrl appUrl "") + } + , H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ] + [ H.div [ A.class "space-y-6" ] + [ viewFormInput + { field = Password + , label = "Password Protection (optional)" + , type_ = "text" + , placeholder = "Enter password to protect this paste" + , help = "Viewers will need this password to access the paste" + , prefix = Nothing + } + ] + , H.div [ A.class "space-y-6" ] + [ viewExpirationTimeSelector + , viewBurnBeforeExpirationCheckbox + ] + ] + , H.div [ A.class "flex justify-end" ] [ viewSubmitButton model ] + ] + + +viewTextarea : Html Msg +viewTextarea = + H.div [ A.class "space-y-2" ] + [ H.label + [ A.for (fromFieldToName Content) + , A.class "block text-sm font-medium text-gray-700 mb-2" + ] + [ H.text "Content" ] + , H.textarea + [ E.onInput (UserUpdatedInput Content) + , A.id (fromFieldToName Content) + , A.placeholder "Write your note here..." + , A.required True + , 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 ] + ] + + +viewExpirationTimeSelector : Html Msg +viewExpirationTimeSelector = + H.div [] + [ H.label [ A.for (fromFieldToName ExpirationTime), A.class "block text-sm font-medium text-gray-700 mb-2" ] [ H.text "Expiration Time (optional)" ] + , H.select + [ A.id (fromFieldToName ExpirationTime) + , 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" + , E.onInput (UserUpdatedInput ExpirationTime) + ] + (List.map + (\e -> + H.option + [ A.value (String.fromInt e.value) ] + [ H.text e.text ] + ) + expirationOptions + ) + ] + + +viewBurnBeforeExpirationCheckbox : Html Msg +viewBurnBeforeExpirationCheckbox = + H.div [ A.class "space-y-2" ] + [ H.div [ A.class "flex items-start space-x-3" ] + [ H.input + [ E.onCheck UserClickedCheckbox + , A.id "burn" + , A.type_ "checkbox" + , A.class "mt-1 h-4 w-4 text-black border-gray-300 rounded focus:ring-black focus:ring-2" + ] + [] + , H.div [ A.class "flex-1" ] + [ H.label [ A.for "burn", A.class "block text-sm font-medium text-gray-700 cursor-pointer" ] + [ H.text "Don't delete note until expiration time, even if it has been read it" ] + ] + ] + ] + + +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 + Content -> + "content" + + Slug -> + "slug" + + Password -> + "password" + + ExpirationTime -> + "expiration" + + + +-- VIEW NOTE CREATED + + +viewNoteCreated : Bool -> 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 "flex gap-3" ] + [ viewCopyLinkButton userClickedCopyLink + , viewCreateNewNoteButton + ] + ] + + +viewCopyLinkButton : Bool -> Html Msg +viewCopyLinkButton isClicked = + let + base : String + 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" + ) + ]
M web/src/Ports.elm

@@ -1,6 +1,9 @@

-port module Ports exposing (sendToLocalStorage) +port module Ports exposing (sendToClipboard, sendToLocalStorage) import Json.Encode port sendToLocalStorage : { key : String, value : Json.Encode.Value } -> Cmd msg + + +port sendToClipboard : String -> Cmd msg
M web/src/Shared.elm

@@ -34,14 +34,16 @@

type alias Flags = { accessToken : Maybe String , refreshToken : Maybe String + , appUrl : String } decoder : Json.Decode.Decoder Flags decoder = - Json.Decode.map2 Flags + Json.Decode.map3 Flags (Json.Decode.field "access_token" (Json.Decode.maybe Json.Decode.string)) (Json.Decode.field "refresh_token" (Json.Decode.maybe Json.Decode.string)) + (Json.Decode.field "app_url" Json.Decode.string)

@@ -57,7 +59,7 @@ init flagsResult _ =

let flags : Flags flags = - flagsResult |> Result.withDefault { accessToken = Nothing, refreshToken = Nothing } + flagsResult |> Result.withDefault { accessToken = Nothing, refreshToken = Nothing, appUrl = "" } maybeCredentials : Maybe Credentials maybeCredentials =

@@ -79,6 +81,7 @@ initModel : Model

initModel = { user = user , timeZone = Time.utc + , appURL = flags.appUrl } in ( initModel
M web/src/Shared/Model.elm

@@ -7,4 +7,5 @@

type alias Model = { user : Auth.User.SignInStatus , timeZone : Time.Zone + , appURL : String }
M web/src/interop.js

@@ -1,9 +1,10 @@

import "./styles.css"; -export const flags = (_) => { +export const flags = ({ env }) => { return { access_token: JSON.parse(window.localStorage.access_token || "null"), refresh_token: JSON.parse(window.localStorage.refresh_token || "null"), + app_url: env.FRONTEND_URL || "http://localhost:3000", }; };

@@ -11,6 +12,16 @@ export const onReady = ({ app }) => {

if (app.ports?.sendToLocalStorage) { app.ports.sendToLocalStorage.subscribe(({ key, value }) => { window.localStorage[key] = JSON.stringify(value); + }); + } + + if (app.ports?.sendToClipboard) { + app.ports.sendToClipboard.subscribe(async (text) => { + try { + await navigator.clipboard.writeText(text); + } catch (error) { + console.error("Failed to write to clipboard:", error); + } }); } };
A web/tests/UnitTests/Data/Note.elm

@@ -0,0 +1,18 @@

+module UnitTests.Data.Note exposing (suite) + +import Data.Note +import Expect +import Json.Decode as D +import Test exposing (Test, describe, test) + + +suite : Test +suite = + describe "Data.Note" + [ test "decodeCreateResponse" + (\_ -> + "{\"slug\":\"the.note-slug\"}" + |> D.decodeString Data.Note.decodeCreateResponse + |> Expect.equal (Ok { slug = "the.note-slug" }) + ) + ]