Media management workspace
https://fve.meiermade.com/components/page-examples/media-management?state=empty&item=photo-302 Workspace
Northwind Outdoor
Nothing to show yet
Change the current selection or return to the populated workspace.
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 ScheduledEvent =
{ id: string
name: string
date: DateOnly
time: string
start: TimeOnly
finish: TimeOnly
people: string
kind: string
photo: string }
let scheduledEvents =
[ { id = "lesson-201"
name = "Coastal trail lesson"
date = DateOnly(2026, 9, 17)
time = "10:00–11:30 AM"
start = TimeOnly(10, 0)
finish = TimeOnly(11, 30)
people = "Maya and Sam · Instructor: Andy Meier"
kind = "Lesson"
photo = "photo-303" }
{ id = "camp-017"
name = "Beginner riding camp"
date = DateOnly(2026, 9, 17)
time = "10:30 AM–12:00 PM"
start = TimeOnly(10, 30)
finish = TimeOnly(12, 0)
people = "3 riders · Instructor: Jordan"
kind = "Camp"
photo = "photo-301" }
{ id = "lesson-202"
name = "Cornering fundamentals"
date = DateOnly(2026, 9, 18)
time = "9:00–10:00 AM"
start = TimeOnly(9, 0)
finish = TimeOnly(10, 0)
people = "Riley · Instructor: Andy Meier"
kind = "Lesson"
photo = "photo-302" }
{ id = "ride-019"
name = "Open track practice"
date = DateOnly(2026, 9, 19)
time = "1:00–3:00 PM"
start = TimeOnly(13, 0)
finish = TimeOnly(15, 0)
people = "6 riders · Supervisor: Jordan"
kind = "Practice"
photo = "photo-304" }
{ id = "return-020"
name = "Equipment return"
date = DateOnly(2026, 9, 24)
time = "4:00–4:30 PM"
start = TimeOnly(16, 0)
finish = TimeOnly(16, 30)
people = "Equipment team · Andy Meier"
kind = "Equipment"
photo = "photo-304" } ]
let eventUrl event =
queryUrl Scheduling { defaultQuery with item = event.id }
type WorkspacePhoto =
{ id: string
name: string
source: string
alt: string
eventId: string }
let initialPhotos =
[ { id = "photo-301"
name = "Camp on the coastal trail"
source = "/images/page-examples/blue.png"
alt = "Solid blue background"
eventId = "camp-017" }
{ id = "photo-302"
name = "Taking the first turn"
source = "/images/page-examples/teal.png"
alt = "Solid teal background"
eventId = "lesson-202" }
{ id = "photo-303"
name = "Before the lesson"
source = "/images/page-examples/amber.png"
alt = "Solid amber background"
eventId = "lesson-201" }
{ id = "photo-304"
name = "A quiet afternoon"
source = "/images/page-examples/green.png"
alt = "Solid green background"
eventId = "ride-019" }
{ id = "photo-305"
name = "A moment to learn"
source = "/images/page-examples/coral.png"
alt = "Solid coral background"
eventId = "camp-017" }
{ id = "photo-306"
name = "Building confidence"
source = "/images/page-examples/violet.png"
alt = "Solid violet background"
eventId = "lesson-201" } ]
let uploadedPhoto =
{ id = "photo-uploaded"
name = "Uploaded fixture preview"
source = "/images/page-examples/violet.png"
alt = "Solid violet background used for the upload success fixture"
eventId = "" }
let photosFor query =
if query.item = uploadedPhoto.id || query.view = "uploaded" then
initialPhotos @ [ uploadedPhoto ]
else
initialPhotos
let mediaEditor (photo: WorkspacePhoto) (feedback: string) =
div {
_id "media-editor"
_class "grid gap-6 lg:grid-cols-2"
figure {
img {
_src photo.source
_alt photo.alt
_class "aspect-video w-full rounded-xl object-cover"
}
figcaption {
_class "mt-3 text-xs text-[var(--fve-muted-text)]"
photo.name
}
}
form {
_method "post"
_action (url MediaManagement + "/save?item=" + photo.id)
_class "grid content-start gap-5"
Input.create "name" "Photo name"
|> Input.withValue photo.name
|> Input.required
|> Input.withAttributes [ _maxlength 120 ]
|> Input.render
Textarea.create "alt" "Image description"
|> Textarea.withValue photo.alt
|> Textarea.withRows 3
|> Textarea.withDescription "Describe the scene for people who cannot see the photograph."
|> Textarea.required
|> Textarea.withAttributes [ _maxlength 500 ]
|> Textarea.render
div {
_role "status"
_class "text-sm text-[var(--fve-muted-text)]"
feedback
}
div {
_class "flex flex-wrap items-center gap-3"
Button.create "Save changes"
|> Button.withVariant ButtonVariant.Primary
|> Button.asSubmit
|> Button.render
link (url MediaManagement) "Back to photos"
}
match scheduledEvents |> List.tryFind (fun event -> event.id = photo.eventId) with
| Some event -> link (eventUrl event) ("Related session: " + event.name)
| None -> ()
}
}
let mediaManagement query =
let photos = photosFor query
let selected =
photos |> List.tryFind (fun (photo: WorkspacePhoto) -> photo.id = query.item)
let uploadBody =
div {
_class "grid gap-5"
form {
_id "media-upload-form"
_method "post"
_action (url MediaManagement + "/upload")
_class "grid gap-5"
Input.create "name" "Photo name"
|> Input.withId "media-upload-name"
|> Input.required
|> Input.withAttributes [ _maxlength 120 ]
|> Input.render
Textarea.create "alt" "Image description"
|> Textarea.withId "media-upload-description"
|> Textarea.withRows 3
|> Textarea.required
|> Textarea.withAttributes [ _maxlength 500 ]
|> Textarea.render
if query.view = "upload-error" then
p {
_role "alert"
_class "text-sm text-[var(--fve-critical-text)]"
"Provide a name and image description."
}
}
FileSelection.create "media-upload-image" "image" "Photograph"
|> FileSelection.withAccept "image/jpeg,image/png,image/webp"
|> FileSelection.withDescription
"Selected files stay in your browser. This documentation fixture never submits or stores their contents."
|> FileSelection.render
}
let uploadDrawer =
Drawer.create "media-upload-drawer" "Upload photograph" uploadBody
|> Drawer.withDescription "Review a safe upload workflow using a repository-owned success fixture."
|> Drawer.withInitialFocus "media-upload-name"
|> Drawer.withFooter (
fragment {
Button.create "Cancel"
|> Button.withAttributes [
_dataOn ("click", "document.getElementById('media-upload-drawer')?.close()") ]
|> Button.render
Button.create "Upload"
|> Button.asSubmit
|> Button.withVariant ButtonVariant.Primary
|> Button.withAttributes [ _form "media-upload-form" ]
|> Button.render
}
)
let uploadAction =
ApplicationAction.command
"document.getElementById('media-upload-drawer')?.showModal(); queueMicrotask(() => document.getElementById('media-upload-name')?.focus())"
"Upload"
|> ApplicationAction.withVariant ButtonVariant.Primary
|> ApplicationAction.withAttributes [
_id "media-upload-drawer-trigger"
_ariaHaspopup "dialog"
_ariaControls "media-upload-drawer" ]
let content, heading =
match selected with
| Some photo ->
mediaEditor
photo
(if query.view = "uploaded" then
"Demo image added. The selected local file was not submitted."
elif query.view = "saved" then
"Changes validated. This resettable example continues to use seeded metadata."
elif query.view = "invalid" then
"Check the name and description. Submitted values are not retained."
else
""),
photo.name
| None ->
let assets =
[ for photo in photos ->
MediaAsset.create
photo.id
photo.name
photo.source
photo.alt
(queryUrl MediaManagement { defaultQuery with item = photo.id })
|> MediaAsset.withDetail ("Session · " + photo.eventId) ]
div {
_class "grid gap-6"
MediaLibrary.create "fieldwork-photos" "Session photographs" "photoIds" assets
|> MediaLibrary.render id
if query.view = "upload-error" then
div {
_class "hidden"
_dataEffect "(() => { const drawer = document.getElementById('media-upload-drawer'); if (drawer && !drawer.open) drawer.showModal(); queueMicrotask(() => document.getElementById('media-upload-name')?.focus()) })()"
}
uploadDrawer |> Drawer.render
},
"Photos"
let actions =
[ if query.state = Ready && selected.IsNone then uploadAction
ApplicationAction.link (url Scheduling) "View schedule" ]
workspace
MediaManagement
heading
"Northwind Outdoor · Session library"
false
actions
(stateContent MediaManagement query content)
div { _class "h-[44rem]"; mediaManagement defaultQuery }
|> Browser.create
|> Browser.withAddress "https://fve.meiermade.com/components/page-examples/media-management?state=ready"
|> Fixture.browser "page-workspace" "Workspace" "/components/page-examples/media-management?state=ready"
|> Fixture.render