mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 09:37:02 +00:00
ui: Much elm work, such wow
- ui: Fix favicon - webui: Changes to support serving Elm UI - Static files now served from `/` mount point. - Old UI handlers moved to `/serve` mount point, some will still be needed by the Elm UI; safe HTML and attachments for example. - Update dev-start.sh for new UI, with tip on how to build it. - ui: Detect browser host:port for websocket URL, - webui: Remove unused mailbox handlers, rename routes - Many routes not needed by Elm UI. - `/serve/mailbox/*` becomes `/serve/m/*`. - webui: Impl custom JSON message API for web UI, - ui: Refactor Mailbox view functions, - ui: Add body tabs for safe HTML and plain text, - webui: Format plain text for new UI, - ui: List attachments with view & download links,
This commit is contained in:
@@ -14,6 +14,9 @@
|
||||
},
|
||||
"/debug": {
|
||||
"target": "http://localhost:9000"
|
||||
},
|
||||
"/serve": {
|
||||
"target": "http://localhost:9000"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -10,8 +10,8 @@
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<title>Elm App</title>
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" type="image/png">
|
||||
<title>Inbucket</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
@@ -13,13 +13,16 @@ type alias Message =
|
||||
, date : String
|
||||
, size : Int
|
||||
, seen : Bool
|
||||
, body : Body
|
||||
, text : String
|
||||
, html : String
|
||||
, attachments : List Attachment
|
||||
}
|
||||
|
||||
|
||||
type alias Body =
|
||||
{ text : String
|
||||
, html : String
|
||||
type alias Attachment =
|
||||
{ id : String
|
||||
, fileName : String
|
||||
, contentType : String
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +37,14 @@ decoder =
|
||||
|> required "date" string
|
||||
|> required "size" int
|
||||
|> required "seen" bool
|
||||
|> required "body" bodyDecoder
|
||||
|
||||
|
||||
bodyDecoder : Decoder Body
|
||||
bodyDecoder =
|
||||
decode Body
|
||||
|> required "text" string
|
||||
|> required "html" string
|
||||
|> required "attachments" (list attachmentDecoder)
|
||||
|
||||
|
||||
attachmentDecoder : Decoder Attachment
|
||||
attachmentDecoder =
|
||||
decode Attachment
|
||||
|> required "id" string
|
||||
|> required "filename" string
|
||||
|> required "content-type" string
|
||||
|
||||
@@ -12,10 +12,12 @@ module Data.Session
|
||||
|
||||
import Json.Decode as Decode exposing (..)
|
||||
import Json.Decode.Pipeline exposing (..)
|
||||
import Navigation exposing (Location)
|
||||
|
||||
|
||||
type alias Session =
|
||||
{ flash : String
|
||||
{ host : String
|
||||
, flash : String
|
||||
, routing : Bool
|
||||
, persistent : Persistent
|
||||
}
|
||||
@@ -35,9 +37,9 @@ type Msg
|
||||
| AddRecent String
|
||||
|
||||
|
||||
init : Persistent -> Session
|
||||
init persistent =
|
||||
Session "" True persistent
|
||||
init : Location -> Persistent -> Session
|
||||
init location persistent =
|
||||
Session location.host "" True persistent
|
||||
|
||||
|
||||
update : Msg -> Session -> Session
|
||||
|
||||
@@ -34,7 +34,7 @@ init : Value -> Location -> ( Model, Cmd Msg )
|
||||
init sessionValue location =
|
||||
let
|
||||
session =
|
||||
Session.init (Session.decodeValueWithDefault sessionValue)
|
||||
Session.init location (Session.decodeValueWithDefault sessionValue)
|
||||
|
||||
model =
|
||||
{ page = Home Home.init
|
||||
@@ -197,7 +197,7 @@ setRoute route model =
|
||||
)
|
||||
|
||||
Route.Monitor ->
|
||||
( { model | page = Monitor Monitor.init }
|
||||
( { model | page = Monitor (Monitor.init model.session.host) }
|
||||
, Ports.windowTitle "Inbucket Monitor"
|
||||
, Session.none
|
||||
)
|
||||
|
||||
@@ -5,21 +5,21 @@ import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||
import Data.Session as Session exposing (Session)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (class, classList, href, id, placeholder, target)
|
||||
import Html.Attributes exposing (class, classList, downloadAs, href, id, property, target)
|
||||
import Html.Events exposing (..)
|
||||
import Http exposing (Error)
|
||||
import HttpUtil
|
||||
import Json.Encode exposing (string)
|
||||
import Ports
|
||||
import Route exposing (Route)
|
||||
|
||||
|
||||
inbucketBase : String
|
||||
inbucketBase =
|
||||
""
|
||||
-- MODEL
|
||||
|
||||
|
||||
|
||||
-- MODEL --
|
||||
type Body
|
||||
= TextBody
|
||||
| SafeHtmlBody
|
||||
|
||||
|
||||
type alias Model =
|
||||
@@ -27,12 +27,13 @@ type alias Model =
|
||||
, selected : Maybe String
|
||||
, headers : List MessageHeader
|
||||
, message : Maybe Message
|
||||
, bodyMode : Body
|
||||
}
|
||||
|
||||
|
||||
init : String -> Maybe String -> Model
|
||||
init name id =
|
||||
Model name id [] Nothing
|
||||
Model name id [] Nothing SafeHtmlBody
|
||||
|
||||
|
||||
load : String -> Cmd Msg
|
||||
@@ -44,7 +45,7 @@ load name =
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
@@ -52,8 +53,9 @@ type Msg
|
||||
| ViewMessage String
|
||||
| DeleteMessage Message
|
||||
| DeleteMessageResult (Result Http.Error ())
|
||||
| NewMailbox (Result Http.Error (List MessageHeader))
|
||||
| NewMessage (Result Http.Error Message)
|
||||
| MailboxResult (Result Http.Error (List MessageHeader))
|
||||
| MessageResult (Result Http.Error Message)
|
||||
| MessageBody Body
|
||||
|
||||
|
||||
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||
@@ -83,7 +85,7 @@ update session msg model =
|
||||
DeleteMessageResult (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
NewMailbox (Ok headers) ->
|
||||
MailboxResult (Ok headers) ->
|
||||
let
|
||||
newModel =
|
||||
{ model | headers = headers }
|
||||
@@ -96,31 +98,47 @@ update session msg model =
|
||||
-- Recurse to select message id.
|
||||
update session (ViewMessage id) newModel
|
||||
|
||||
NewMailbox (Err err) ->
|
||||
MailboxResult (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
NewMessage (Ok msg) ->
|
||||
( { model | message = Just msg }, Cmd.none, Session.none )
|
||||
MessageResult (Ok msg) ->
|
||||
let
|
||||
bodyMode =
|
||||
if msg.html == "" then
|
||||
TextBody
|
||||
else
|
||||
model.bodyMode
|
||||
in
|
||||
( { model
|
||||
| message = Just msg
|
||||
, bodyMode = bodyMode
|
||||
}
|
||||
, Cmd.none
|
||||
, Session.none
|
||||
)
|
||||
|
||||
NewMessage (Err err) ->
|
||||
MessageResult (Err err) ->
|
||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||
|
||||
MessageBody bodyMode ->
|
||||
( { model | bodyMode = bodyMode }, Cmd.none, Session.none )
|
||||
|
||||
|
||||
getMailbox : String -> Cmd Msg
|
||||
getMailbox name =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ name
|
||||
"/api/v1/mailbox/" ++ name
|
||||
in
|
||||
Http.get url (Decode.list MessageHeader.decoder)
|
||||
|> Http.send NewMailbox
|
||||
|> Http.send MailboxResult
|
||||
|
||||
|
||||
deleteMessage : Model -> Message -> ( Model, Cmd Msg, Session.Msg )
|
||||
deleteMessage model msg =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
|
||||
"/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
|
||||
|
||||
cmd =
|
||||
HttpUtil.delete url
|
||||
@@ -140,35 +158,46 @@ getMessage : String -> String -> Cmd Msg
|
||||
getMessage mailbox id =
|
||||
let
|
||||
url =
|
||||
inbucketBase ++ "/api/v1/mailbox/" ++ mailbox ++ "/" ++ id
|
||||
"/serve/m/" ++ mailbox ++ "/" ++ id
|
||||
in
|
||||
Http.get url Message.decoder
|
||||
|> Http.send NewMessage
|
||||
|> Http.send MessageResult
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
view session model =
|
||||
div [ id "page", class "mailbox" ]
|
||||
[ aside [ id "message-list" ] [ viewMailbox model ]
|
||||
, main_ [ id "message" ] [ viewMessage model ]
|
||||
[ aside [ id "message-list" ] [ messageList model ]
|
||||
, main_
|
||||
[ id "message" ]
|
||||
[ case model.message of
|
||||
Just message ->
|
||||
viewMessage message model.bodyMode
|
||||
|
||||
Nothing ->
|
||||
text
|
||||
("Select a message on the left,"
|
||||
++ " or enter a different username into the box on upper right."
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewMailbox : Model -> Html Msg
|
||||
viewMailbox model =
|
||||
div [] (List.map (viewHeader model) (List.reverse model.headers))
|
||||
messageList : Model -> Html Msg
|
||||
messageList model =
|
||||
div [] (List.map (messageChip model.selected) (List.reverse model.headers))
|
||||
|
||||
|
||||
viewHeader : Model -> MessageHeader -> Html Msg
|
||||
viewHeader mailbox msg =
|
||||
messageChip : Maybe String -> MessageHeader -> Html Msg
|
||||
messageChip selected msg =
|
||||
div
|
||||
[ classList
|
||||
[ ( "message-list-entry", True )
|
||||
, ( "selected", mailbox.selected == Just msg.id )
|
||||
, ( "selected", selected == Just msg.id )
|
||||
, ( "unseen", not msg.seen )
|
||||
]
|
||||
, onClick (ClickMessage msg.id)
|
||||
@@ -179,38 +208,92 @@ viewHeader mailbox msg =
|
||||
]
|
||||
|
||||
|
||||
viewMessage : Model -> Html Msg
|
||||
viewMessage model =
|
||||
case model.message of
|
||||
Just message ->
|
||||
div []
|
||||
[ div [ class "button-bar" ]
|
||||
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
|
||||
, a
|
||||
[ href
|
||||
(inbucketBase
|
||||
++ "/mailbox/"
|
||||
++ message.mailbox
|
||||
++ "/"
|
||||
++ message.id
|
||||
++ "/source"
|
||||
)
|
||||
, target "_blank"
|
||||
]
|
||||
[ button [] [ text "Source" ] ]
|
||||
]
|
||||
, dl [ id "message-header" ]
|
||||
[ dt [] [ text "From:" ]
|
||||
, dd [] [ text message.from ]
|
||||
, dt [] [ text "To:" ]
|
||||
, dd [] (List.map text message.to)
|
||||
, dt [] [ text "Date:" ]
|
||||
, dd [] [ text message.date ]
|
||||
, dt [] [ text "Subject:" ]
|
||||
, dd [] [ text message.subject ]
|
||||
]
|
||||
, article [] [ text message.body.text ]
|
||||
viewMessage : Message -> Body -> Html Msg
|
||||
viewMessage message bodyMode =
|
||||
let
|
||||
sourceUrl message =
|
||||
"/serve/m/" ++ message.mailbox ++ "/" ++ message.id ++ "/source"
|
||||
in
|
||||
div []
|
||||
[ div [ class "button-bar" ]
|
||||
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
|
||||
, a
|
||||
[ href (sourceUrl message), target "_blank" ]
|
||||
[ button [] [ text "Source" ] ]
|
||||
]
|
||||
, dl [ id "message-header" ]
|
||||
[ dt [] [ text "From:" ]
|
||||
, dd [] [ text message.from ]
|
||||
, dt [] [ text "To:" ]
|
||||
, dd [] (List.map text message.to)
|
||||
, dt [] [ text "Date:" ]
|
||||
, dd [] [ text message.date ]
|
||||
, dt [] [ text "Subject:" ]
|
||||
, dd [] [ text message.subject ]
|
||||
]
|
||||
, messageBody message bodyMode
|
||||
, attachments message
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
text ""
|
||||
|
||||
messageBody : Message -> Body -> Html Msg
|
||||
messageBody message bodyMode =
|
||||
let
|
||||
bodyModeTab mode label =
|
||||
a
|
||||
[ classList [ ( "active", bodyMode == mode ) ]
|
||||
, onClick (MessageBody mode)
|
||||
, href "javacript:void(0)"
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
safeHtml =
|
||||
bodyModeTab SafeHtmlBody "Safe HTML"
|
||||
|
||||
plainText =
|
||||
bodyModeTab TextBody "Plain Text"
|
||||
|
||||
tabs =
|
||||
if message.html == "" then
|
||||
[ plainText ]
|
||||
else
|
||||
[ safeHtml, plainText ]
|
||||
in
|
||||
div [ class "tab-panel" ]
|
||||
[ nav [ class "tab-bar" ] tabs
|
||||
, article [ class "message-body" ]
|
||||
[ case bodyMode of
|
||||
SafeHtmlBody ->
|
||||
div [ property "innerHTML" (string message.html) ] []
|
||||
|
||||
TextBody ->
|
||||
div [ property "innerHTML" (string message.text) ] []
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
attachments : Message -> Html Msg
|
||||
attachments message =
|
||||
let
|
||||
baseUrl =
|
||||
"/serve/m/attach/" ++ message.mailbox ++ "/" ++ message.id ++ "/"
|
||||
in
|
||||
if List.isEmpty message.attachments then
|
||||
div [] []
|
||||
else
|
||||
table [ class "attachments well" ] (List.map (attachmentRow baseUrl) message.attachments)
|
||||
|
||||
|
||||
attachmentRow : String -> Message.Attachment -> Html Msg
|
||||
attachmentRow baseUrl attach =
|
||||
let
|
||||
url =
|
||||
baseUrl ++ attach.id ++ "/" ++ attach.fileName
|
||||
in
|
||||
tr []
|
||||
[ td []
|
||||
[ a [ href url, target "_blank" ] [ text attach.fileName ]
|
||||
, text (" (" ++ attach.contentType ++ ") ")
|
||||
]
|
||||
, td [] [ a [ href url, downloadAs attach.fileName, class "button" ] [ text "Download" ] ]
|
||||
]
|
||||
|
||||
@@ -10,30 +10,34 @@ import Route
|
||||
import WebSocket
|
||||
|
||||
|
||||
-- MODEL --
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ messages : List MessageHeader }
|
||||
{ wsUrl : String
|
||||
, messages : List MessageHeader
|
||||
}
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ messages = [] }
|
||||
init : String -> Model
|
||||
init host =
|
||||
{ wsUrl = "ws://" ++ host ++ "/api/v1/monitor/messages"
|
||||
, messages = []
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS --
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
WebSocket.listen "ws://192.168.1.10:3000/api/v1/monitor/messages"
|
||||
WebSocket.listen model.wsUrl
|
||||
(decodeString MessageHeader.decoder >> NewMessage)
|
||||
|
||||
|
||||
|
||||
-- UPDATE --
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
@@ -58,7 +62,7 @@ update session msg model =
|
||||
|
||||
|
||||
|
||||
-- VIEW --
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Session -> Model -> Html Msg
|
||||
|
||||
@@ -30,6 +30,11 @@ time, mark, audio, video {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #337ab7;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
@@ -46,6 +51,33 @@ body, input, table {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/** SHARED */
|
||||
|
||||
a.button {
|
||||
background-color: #337ab7;
|
||||
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
margin: 4px;
|
||||
padding: 3px 8px;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.well {
|
||||
background-color: var(--selected-color);
|
||||
background-image: linear-gradient(to bottom, #e8e8e8 0, #f5f5f5 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
padding: 4px 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/** APP */
|
||||
|
||||
#app {
|
||||
@@ -313,6 +345,46 @@ li.navbar-active span,
|
||||
}
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
nav.tab-bar {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
margin: 20px 0 10px 0;
|
||||
}
|
||||
|
||||
nav.tab-bar a {
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: block;
|
||||
margin-bottom: -1px;
|
||||
margin-right: 2px;
|
||||
padding: 8px 15px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.tab-bar a.active {
|
||||
color: var(--low-color);
|
||||
border-color: var(--border-color) var(--border-color) var(--bg-color) var(--border-color);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
nav.tab-bar a:focus,
|
||||
nav.tab-bar a:hover {
|
||||
background-color: var(--selected-color);
|
||||
}
|
||||
|
||||
nav.tab-bar a.active:focus,
|
||||
nav.tab-bar a.active:hover {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/** STATUS */
|
||||
|
||||
.metric-panel {
|
||||
|
||||
Reference in New Issue
Block a user