8 files changed,
527 insertions(+),
21 deletions(-)
Author:
Smirnov Olexandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-07-01 13:25:47 +0300
Parent:
1994b67
M
web/elm.json
@@ -12,6 +12,7 @@ "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", "jweir/elm-iso8601": "7.0.1",
M
web/src/Api.elm
@@ -1,4 +1,4 @@
-module Api exposing (Error(..), Response(..), errorMessage) +module Api exposing (Error(..), Response(..), errorMessage, is404) import Http import Json.Decode@@ -29,3 +29,13 @@ err.message
JsonDecodeError err -> err.message + + +is404 : Error -> Bool +is404 error = + case error of + HttpError { reason } -> + reason == Http.BadStatus 404 + + _ -> + False
M
web/src/Api/Note.elm
@@ -1,12 +1,13 @@
-module Api.Note exposing (create) +module Api.Note exposing (create, get, getMetadata) import Api -import Data.Note as Note exposing (CreateResponse) +import Data.Note as Note exposing (CreateResponse, Metadata, Note) import Effect exposing (Effect) import Http import ISO8601 import Json.Encode as E import Time exposing (Posix) +import Url create :@@ -20,22 +21,21 @@ }
-> Effect msg create options = let + encodeMaybe : Maybe a -> b -> (a -> E.Value) -> ( b, E.Value ) + encodeMaybe maybeData field value = + case maybeData of + Just data -> + ( field, value data ) + + Nothing -> + ( field, E.null ) + 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 ) + , encodeMaybe options.slug "slug" E.string + , encodeMaybe options.password "password" E.string , ( "burn_before_expiration", E.bool options.burnBeforeExpiration ) , if options.expiresAt == Time.millisToPosix 0 then ( "expires_at", E.null )@@ -56,3 +56,43 @@ , body = Http.jsonBody body
, onResponse = options.onResponse , decoder = Note.decodeCreateResponse } + + +get : + { onResponse : Result Api.Error Note -> msg + , slug : String + , password : Maybe String + } + -> Effect msg +get options = + Effect.sendApiRequest + { endpoint = + "/api/v1/note/" + ++ options.slug + ++ (case options.password of + Just p -> + "?password=" ++ Url.percentEncode p + + Nothing -> + "" + ) + , method = "GET" + , body = Http.emptyBody + , onResponse = options.onResponse + , decoder = Note.decode + } + + +getMetadata : + { onResponse : Result Api.Error Metadata -> msg + , slug : String + } + -> Effect msg +getMetadata options = + Effect.sendApiRequest + { endpoint = "/api/v1/note/" ++ options.slug ++ "/meta" + , method = "GET" + , body = Http.emptyBody + , onResponse = options.onResponse + , decoder = Note.decodeMetadata + }
A
web/src/Components/Note.elm
@@ -0,0 +1,65 @@
+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" + ] + [] + ]
M
web/src/Data/Note.elm
@@ -1,4 +1,4 @@
-module Data.Note exposing (CreateResponse, decodeCreateResponse) +module Data.Note exposing (CreateResponse, Metadata, Note, decode, decodeCreateResponse, decodeMetadata) import Json.Decode as D exposing (Decoder)@@ -11,3 +11,35 @@ decodeCreateResponse : Decoder CreateResponse
decodeCreateResponse = D.map CreateResponse (D.field "slug" D.string) + + +type alias Note = + { content : String + , readAt : Maybe String -- TODO: use Posix + , burnBeforeExpiration : Bool + , createdAt : String -- TODO: use Posix + , expiresAt : Maybe String -- TODO: use Posix + } + + +decode : Decoder Note +decode = + D.map5 Note + (D.field "content" D.string) + (D.maybe (D.field "read_at" D.string)) + (D.field "burn_before_expiration" D.bool) + (D.field "created_at" D.string) + (D.maybe (D.field "expires_at" D.string)) + + +type alias Metadata = + { createdAt : String -- TODO: use Posix + , hasPassword : Bool + } + + +decodeMetadata : Decoder Metadata +decodeMetadata = + D.map2 Metadata + (D.field "created_at" D.string) + (D.field "has_password" D.bool)
M
web/src/Effect.elm
@@ -377,12 +377,17 @@ Err err ->
Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err }) Http.BadStatus_ { statusCode } body -> - case Json.Decode.decodeString Data.Error.decode body of - Ok err -> - Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode }) + case body of + "" -> + 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 }) - 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 })
A
web/src/Pages/Secret/Slug_.elm
@@ -0,0 +1,342 @@
+module Pages.Secret.Slug_ exposing (Model, Msg, PageVariant, page) + +import Api +import Api.Note +import Components.Error +import Components.Note +import Data.Note exposing (Metadata, Note) +import Effect exposing (Effect) +import Html as H exposing (Html) +import Html.Attributes as A +import Html.Events as E +import Layouts +import Page exposing (Page) +import Route exposing (Route) +import Shared +import View exposing (View) + + +page : Shared.Model -> Route { slug : String } -> Page Model Msg +page _ route = + Page.new + { init = init route.params.slug + , update = update + , subscriptions = subscriptions + , view = view + } + |> Page.withLayout (\_ -> Layouts.Header {}) + + + +-- INIT + + +type PageVariant + = RequestNote + | ShowNote (Api.Response Note) + | NotFound + + +type alias Model = + { page : PageVariant + , metadata : Api.Response Metadata + , slug : String + , password : Maybe String + } + + +init : String -> () -> ( Model, Effect Msg ) +init slug () = + ( { page = RequestNote + , metadata = Api.Loading + , slug = slug + , password = Nothing + } + , Api.Note.getMetadata + { onResponse = ApiGetMetadataResponded + , slug = slug + } + ) + + + +-- UPDATE + + +type Msg + = UserClickedViewNote + | UserClickedCopyContent + | UserUpdatedPassword String + | ApiGetNoteResponded (Result Api.Error Note) + | ApiGetMetadataResponded (Result Api.Error Metadata) + + +update : Msg -> Model -> ( Model, Effect Msg ) +update msg model = + case msg of + UserClickedViewNote -> + ( { model | page = ShowNote Api.Loading } + , Api.Note.get + { onResponse = ApiGetNoteResponded + , password = model.password + , slug = model.slug + } + ) + + UserClickedCopyContent -> + case model.page of + ShowNote (Api.Success note) -> + ( model, Effect.sendToClipboard note.content ) + + _ -> + ( model, Effect.none ) + + UserUpdatedPassword password -> + ( { model | password = Just password }, Effect.none ) + + ApiGetNoteResponded (Ok note) -> + ( { model | page = ShowNote (Api.Success note) }, Effect.none ) + + ApiGetNoteResponded (Err error) -> + ( { model | page = ShowNote (Api.Failure error) }, Effect.none ) + + ApiGetMetadataResponded (Ok metadata) -> + ( { model | metadata = Api.Success metadata }, Effect.none ) + + ApiGetMetadataResponded (Err error) -> + ( { model | page = NotFound, metadata = Api.Failure error }, Effect.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none + + + +-- VIEW + + +view : Model -> View Msg +view 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 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.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 + + else + Components.Error.error (Api.errorMessage error) + ] + ) + ] + ] + } + + +viewPage : String -> PageVariant -> Metadata -> Maybe String -> List (Html Msg) +viewPage slug variant metadata password = + case variant of + RequestNote -> + [ viewHeader { title = "View note", subtitle = "Click the button below to view the note content" } + , viewOpenNote { slug = slug, hasPassword = metadata.hasPassword, password = password, isLoading = False } + ] + + ShowNote apiResp -> + case apiResp of + Api.Success note -> + [ viewShowNoteHeader slug note + , viewNoteContent note + ] + + Api.Loading -> + [ viewHeader { title = "View note", subtitle = "Click the button below to view the note content" } + , viewOpenNote { slug = slug, hasPassword = metadata.hasPassword, password = password, isLoading = True } + ] + + Api.Failure _ -> + [ viewHeader { title = "Note Not Found", subtitle = "The note you're looking for doesn't exist or has expired" } + , viewNoteNotFound slug + ] + + NotFound -> + [ viewNoteNotFound slug ] + + + +-- HEADER + + +viewHeader : { title : String, subtitle : String } -> Html msg +viewHeader options = + 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 options.title ] + , H.p [ A.class "text-gray-600 mt-2" ] [ H.text options.subtitle ] + ] + + +viewShowNoteHeader : String -> Note -> Html Msg +viewShowNoteHeader slug note = + H.div [] + [ if note.burnBeforeExpiration then + 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 ] + , 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: " ++ note.createdAt) ] + , case note.expiresAt of + Just expiresAt -> + -- TODO: format time properly + H.p [] [ H.text ("Expires at: " ++ expiresAt) ] + + Nothing -> + H.text "" + ] + ] + , 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" ] + ] + ] + ] + ] + + + +-- NOTE + + +viewNoteNotFound : String -> Html msg +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 ] + , 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" ] + ] + ] + ] + ] + ] + + +viewOpenNote : + { slug : String + , hasPassword : Bool + , isLoading : Bool + , password : Maybe String + } + -> Html Msg +viewOpenNote opts = + let + isDisabled : Bool + isDisabled = + opts.hasPassword && Maybe.withDefault "" opts.password == "" + + buttonData : { text : String, class : String } + buttonData = + let + base : String + 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" } + 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.Note.noteIconSvg ] + , 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." ] + ] + , H.form + [ E.onSubmit UserClickedViewNote + , A.class "max-w-sm mx-auto space-y-4" + ] + [ if opts.hasPassword then + 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" + ] + [] + ] + + else + H.text "" + , H.button + [ A.class buttonData.class + , A.type_ "submit" + , A.disabled isDisabled + ] + [ H.text buttonData.text ] + ] + ] + ] + + +viewNoteContent : Note -> Html msg +viewNoteContent note = + H.div [ A.class "p-6" ] + [ H.div [ A.class "bg-gray-50 border border-gray-200 rounded-md p-4" ] + [ H.pre + [ A.class "whitespace-pre-wrap font-mono text-sm text-gray-800 overflow-x-auto" ] + [ H.text note.content ] + ] + ]
M
web/tests/UnitTests/Data/Note.elm
@@ -15,4 +15,15 @@ "{\"slug\":\"the.note-slug\"}"
|> D.decodeString Data.Note.decodeCreateResponse |> Expect.equal (Ok { slug = "the.note-slug" }) ) + , test "decodeMetadata" + (\_ -> + """ + { + "created_at": "2023-10-01T12:00:00Z", + "has_password": false + } + """ + |> D.decodeString Data.Note.decodeMetadata + |> Expect.equal (Ok { createdAt = "2023-10-01T12:00:00Z", hasPassword = False }) + ) ]