Media management workspace
https://fve.meiermade.com/components/page-examples/media-management?state=ready&item=photo-305 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 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