Messaging workspace
https://fve.meiermade.com/components/page-examples/messaging?state=loading&item=jordan Workspace
Northwind Outdoor
open System
open FSharp.ViewEngine
open Acme.Components.Primitives
open Acme.Components.Application
open Acme.Components.Documentation
open type Html
open type Svg
open type Datastar
type ExamplePage =
| DependencyGraph
| ExecutionDetail
| FinancialReporting
| Messaging
| Operations
| Scheduling
| MediaManagement
type ReviewState =
| Setup
| Ready
| Loading
| Empty
| Failed
type ExampleQuery =
{ state: ReviewState
item: string
view: string
range: string }
let defaultQuery =
{ state = Ready
item = ""
view = ""
range = "" }
let slug =
function
| DependencyGraph -> "dependency-graph"
| ExecutionDetail -> "execution-detail"
| FinancialReporting -> "financial-reporting"
| Messaging -> "messaging"
| Operations -> "operations-dashboard"
| Scheduling -> "scheduling"
| MediaManagement -> "media-management"
let title =
function
| DependencyGraph -> "Dependency graph"
| ExecutionDetail -> "Execution detail"
| FinancialReporting -> "Financial reporting"
| Messaging -> "Messaging"
| Operations -> "Operations dashboard"
| Scheduling -> "Scheduling"
| MediaManagement -> "Media management"
let url page =
"/components/page-examples/" + slug page
let stateKey =
function
| Setup -> "setup"
| Ready -> "ready"
| Loading -> "loading"
| Empty -> "empty"
| Failed -> "error"
let queryUrl page query =
let pairs =
[ "state", stateKey query.state
"item", query.item
"view", query.view
"range", query.range ]
url page
+ "?"
+ (pairs
|> List.filter (snd >> String.IsNullOrEmpty >> not)
|> List.map (fun (key, value) -> key + "=" + Uri.EscapeDataString value)
|> String.concat "&")
let link (href: string) (label: string) : HtmlElement =
a {
_href href
_class
"font-medium text-[var(--fve-brand-text)] underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-[var(--fve-brand-ring)]"
label
}
let section (heading: string) (content: HtmlElement) =
Section.create (SectionHeader.create heading |> SectionHeader.withDivider) content
|> Section.render id
let details (fields: (string * string) list) =
DescriptionList.create [ for label, value in fields -> DetailField.text label value ]
|> DescriptionList.withColumns DescriptionListColumns.Three
|> DescriptionList.render
let reviewStates page =
(if page = Operations then [ Setup, "Setup" ] else [])
@ [ Ready, "Populated"; Loading, "Loading"; Empty, "Empty"; Failed, "Error" ]
let stateContent page query content =
match query.state with
| Setup
| Ready -> content
| Loading ->
div {
_class "p-8"
LoadingIndicator.create ("Loading " + (title page).ToLowerInvariant())
|> LoadingIndicator.withVisibleLabel
|> LoadingIndicator.render
}
| Empty ->
EmptyState.create "Nothing to show yet" "Change the current selection or return to the populated workspace."
|> EmptyState.withActions (link (queryUrl page { query with state = Ready }) "Return to workspace")
|> EmptyState.render
| Failed ->
Notice.create
"workspace-error"
"This view could not be loaded"
(p { "Your selection is preserved. Try loading it again." })
|> Notice.withTone Tone.Critical
|> Notice.withActions (link (queryUrl page { query with state = Ready }) "Try again")
|> Notice.render
/// Place durable workspace identity above the scrollable destination list.
let sideNavWorkspace (name: string) =
div {
p {
_class "text-xs font-semibold uppercase tracking-wide text-[var(--fve-muted-text)]"
"Workspace"
}
p {
_class "mt-1 truncate text-sm font-semibold"
name
}
}
let workspace page heading subtitle canvas actions content =
let product, workspaceName, destinations =
match page with
| DependencyGraph
| ExecutionDetail ->
"Relay", "Customer analytics", [ DependencyGraph, "Dependencies"; ExecutionDetail, "Executions" ]
| FinancialReporting -> "Ledger", "Northwind Studio", [ FinancialReporting, "Overview" ]
| Messaging -> "Gather", "Northwind Outdoor", [ Messaging, "Messages" ]
| _ ->
"Fieldwork", "Northwind Outdoor", [ Operations, "Overview"; Scheduling, "Schedule"; MediaManagement, "Photos" ]
let items =
[ for destination, label in destinations -> SideNavItem.create destination label ]
let navigation =
SideNav.create
(slug page + "-nav")
(product + " navigation")
(SideNavHeader.create product)
[ SideNavSection.ungrouped items ]
|> SideNav.withCurrent page
|> SideNav.withWidth SideNavWidth.Narrow
|> SideNav.withContext (sideNavWorkspace workspaceName)
|> SideNav.withMobileContext (sideNavWorkspace workspaceName)
|> SideNav.withFooter (
div {
_class "flex min-w-0 items-center gap-3"
Avatar.create "Andy Meier" "AM" |> Avatar.render
p {
_class "truncate text-sm font-medium"
"Andy Meier"
}
}
)
let header =
PageHeader.create heading
|> PageHeader.withSubtitle subtitle
|> PageHeader.withActions (ActionCluster.create (slug page + "-actions") actions)
let topBar =
PageTopBar.create ()
|> PageTopBar.withContent (
div {
_class "flex min-h-[var(--fve-shell-bar-min-height)] items-center px-4 sm:px-6 lg:px-8"
Breadcrumbs.create
(slug page + "-breadcrumbs")
(product + " breadcrumb")
[ BreadcrumbItem.create page product; BreadcrumbItem.create page heading ]
|> Breadcrumbs.render url
}
)
let body =
Page.create header content
|> Page.withTopBar topBar
|> Page.withWidth PageWidth.Full
|> Page.withBodyLayout (
if canvas then
PageBodyLayout.Canvas
else
PageBodyLayout.Padded
)
|> Page.render id
AppShell.create (slug page + "-shell") navigation body
|> AppShell.withTheme (
ComponentsTheme.sky
|> ComponentsTheme.withDensity Density.Compact
|> ComponentsTheme.withControlSize ControlSize.Small)
|> AppShell.withBoundary AppShellBoundary.Container
|> AppShell.asPreview (product + " workspace")
|> AppShell.render url
type Conversation =
{ id: string
name: string
initials: string
participants: string
preview: string }
type Message =
{ id: string
conversation: string
author: string
body: string
time: string
outgoing: bool }
let conversations =
[ { id = "beach"
name = "Beach weekend"
initials = "BW"
participants = "Andy, Jordan, Kella"
preview = "I found a place near the beach." }
{ id = "studio"
name = "Studio team"
initials = "ST"
participants = "Andy, Maya"
preview = "The new photographs look great." }
{ id = "jordan"
name = "Jordan Lee"
initials = "JL"
participants = "Jordan Lee"
preview = "See you at 10:30 tomorrow." } ]
let initialMessages =
[ { id = "m1"
conversation = "beach"
author = "Jordan"
body = "Are we still thinking Saturday for the coast? The weather looks good."
time = "09:12"
outgoing = false }
{ id = "m2"
conversation = "beach"
author = "Andy Meier"
body = "Yes! Let's leave after breakfast. I'll bring the picnic."
time = "09:14"
outgoing = true }
{ id = "m3"
conversation = "beach"
author = "Kella"
body = "I found a place near the beach. There's parking and a short trail down to the water."
time = "09:18"
outgoing = false }
{ id = "m4"
conversation = "beach"
author = "Andy Meier"
body = "That sounds perfect. Could you send us the meeting point?"
time = "09:20"
outgoing = true }
{ id = "m5"
conversation = "studio"
author = "Maya"
body = "The new photographs look great. Can you send the final selection this afternoon?"
time = "08:45"
outgoing = false }
{ id = "m6"
conversation = "jordan"
author = "Jordan"
body = "See you at 10:30 tomorrow. I'll meet you by the entrance."
time = "Yesterday"
outgoing = false } ]
let private sentMessage =
{ id = "message-sent"
conversation = "beach"
author = "Andy Meier"
body = "Meet at the east entrance 15 minutes before we leave."
time = "Just now"
outgoing = true }
let currentConversation query =
conversations
|> List.tryFind (fun item -> item.id = query.item)
|> Option.defaultValue conversations.Head
let messagesFor query =
if query.view = "sent" then
initialMessages @ [ { sentMessage with conversation = (currentConversation query).id } ]
else
initialMessages
let messageHistory conversation messages =
div {
_id "message-history"
_tabindex 0
_role "log"
_ariaLabel (conversation.name + " message history")
_ariaLive "polite"
_class "min-h-0 flex-1 space-y-5 overflow-y-auto p-4 sm:p-6"
p {
_class "text-center text-xs text-[var(--fve-muted-text)]"
"Thursday, September 17"
}
for message in messages |> List.filter (fun message -> message.conversation = conversation.id) do
div {
_id ("message-" + message.id)
_dataInit "el.parentElement.scrollTop = el.parentElement.scrollHeight"
_class (
if message.outgoing then
"ml-auto grid max-w-[85%] justify-items-end gap-1"
else
"mr-auto grid max-w-[85%] gap-1"
)
p {
_class "text-xs text-[var(--fve-muted-text)]"
message.author + " · " + message.time
}
p {
_class (
if message.outgoing then
"whitespace-pre-wrap break-words rounded-2xl rounded-br-sm bg-[var(--fve-brand-solid)] px-4 py-3 text-base text-white"
else
"whitespace-pre-wrap break-words rounded-2xl rounded-bl-sm bg-[var(--fve-surface-subtle)] px-4 py-3 text-base text-[var(--fve-text)]"
)
message.body
}
}
}
let conversationPanel query messages draft error =
let conversation = currentConversation query
div {
_id "conversation-panel"
_class "flex h-full min-h-0 flex-col"
header {
_class "flex items-center gap-3 border-b border-[var(--fve-border)] p-4"
Avatar.create conversation.name conversation.initials |> Avatar.render
div {
h2 {
_class "text-base font-semibold"
conversation.name
}
p {
_class "text-xs text-[var(--fve-muted-text)]"
conversation.participants
}
}
}
messageHistory conversation messages
form {
_method "post"
_action (url Messaging + "/send?item=" + conversation.id)
_class "grid shrink-0 gap-3 border-t border-[var(--fve-border)] bg-[var(--fve-surface)] p-4"
let field =
Textarea.create "message" "Message"
|> Textarea.withId "message-compose"
|> Textarea.withRows 2
|> Textarea.withValue draft
|> Textarea.required
|> Textarea.withAttributes [ _maxlength 2000 ]
(match error with
| Some message -> field |> Textarea.withValidation message
| None -> field)
|> Textarea.render
div {
_class "flex flex-wrap items-center justify-between gap-3"
p {
_role "status"
_class "text-xs text-[var(--fve-muted-text)]"
if query.view = "sent" then
"Message accepted. This deterministic example does not retain submitted text."
else
"Only participants in this conversation can see your reply."
}
Button.create "Send message"
|> Button.withVariant ButtonVariant.Primary
|> Button.asSubmit
|> Button.render
}
}
}
let messaging query =
let current = currentConversation query
let messages = messagesFor query
let error =
match query.view with
| "message-empty" -> Some "Enter a message before sending."
| "message-too-long" -> Some "Keep your message under 2,000 characters."
| _ -> None
let destination id =
queryUrl Messaging { defaultQuery with item = id }
let navigation =
SideNav.create
"gather-conversations"
"Conversations"
(SideNavHeader.create "Gather")
[ SideNavSection.group
"Messages"
[ for conversation in conversations -> SideNavItem.create conversation.id conversation.name ] ]
|> SideNav.withCurrent current.id
|> SideNav.withWidth SideNavWidth.Wide
|> SideNav.withContext (sideNavWorkspace "Northwind Outdoor")
|> SideNav.withMobileContext (sideNavWorkspace "Northwind Outdoor")
|> SideNav.withFooter (
div {
_class "flex items-center gap-3"
Avatar.create "Andy Meier" "AM" |> Avatar.render
span {
_class "text-sm font-medium"
"Andy Meier"
}
}
)
let content =
Html.section {
_ariaLabel "Current conversation"
_class "h-full min-h-0"
conversationPanel query messages "" error
}
let page =
Page.create
(PageHeader.create "Messages"
|> PageHeader.withSubtitle "Your conversations, together in one place.")
(stateContent Messaging query content)
|> Page.withTopBar (
PageTopBar.create ()
|> PageTopBar.withContent (
div {
_class "flex min-h-[var(--fve-shell-bar-min-height)] items-center px-4 sm:px-6 lg:px-8"
Breadcrumbs.create
"gather-breadcrumbs"
"Gather breadcrumb"
[ BreadcrumbItem.create "beach" "Gather"
BreadcrumbItem.create current.id "Messages" ]
|> Breadcrumbs.render destination
}
)
)
|> Page.withBodyLayout PageBodyLayout.Canvas
|> Page.render destination
AppShell.create "messaging-shell" navigation page
|> AppShell.withTheme (
ComponentsTheme.sky
|> ComponentsTheme.withDensity Density.Compact
|> ComponentsTheme.withControlSize ControlSize.Small)
|> AppShell.withBoundary AppShellBoundary.Container
|> AppShell.asPreview "Gather workspace"
|> AppShell.render destination
div { _class "h-[44rem]"; messaging defaultQuery }
|> Browser.create
|> Browser.withAddress "https://fve.meiermade.com/components/page-examples/messaging?state=ready"
|> Fixture.browser "page-workspace" "Workspace" "/components/page-examples/messaging?state=ready"
|> Fixture.render