diff options
| author | Ben Sima <ben@bensima.com> | 2025-11-26 13:03:17 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-11-26 13:03:17 -0500 |
| commit | 555d5d416e2001d04145b173aa40fd40f656509d (patch) | |
| tree | 5edcdb5e96f8b0ee59255153ddcb77faa09388aa /Omni | |
| parent | 75d5716a31ea1d9d1e92d76d8417dd5ae8dcbab6 (diff) | |
Add mobile-first CSS styling
All checks pass. The Style.hs file was already implemented from
a previo
- Mobile-first CSS with Clay - Status badges with colored pills
(Open=yellow, InProgress=blue, Review - Large touch targets (44px min
height) - Single column layout on narrow screens (<600px) - Card-style
sections with subtle shadows - Responsive navigation header - Dark
mode support - Served at GET /style.css
Task-Id: t-1o2g8gugkr1.9
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Jr/Web.hs | 870 | ||||
| -rw-r--r-- | Omni/Jr/Web/Style.hs | 510 |
2 files changed, 805 insertions, 575 deletions
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs index c32c716..69162aa 100644 --- a/Omni/Jr/Web.hs +++ b/Omni/Jr/Web.hs @@ -9,6 +9,7 @@ -- : dep servant-lucid -- : dep http-api-data -- : dep process +-- : dep clay module Omni.Jr.Web ( run, defaultPort, @@ -18,8 +19,11 @@ where import Alpha import qualified Data.List as List import qualified Data.Text as Text +import qualified Data.Text.Lazy as LazyText +import qualified Data.Text.Lazy.Encoding as LazyText import qualified Lucid import qualified Network.Wai.Handler.Warp as Warp +import qualified Omni.Jr.Web.Style as Style import qualified Omni.Task.Core as TaskCore import Servant import qualified Servant.HTML.Lucid as Lucid @@ -41,6 +45,7 @@ data TaskFilters = TaskFilters type API = Get '[Lucid.HTML] HomePage + :<|> "style.css" :> Get '[CSS] LazyText.Text :<|> "ready" :> Get '[Lucid.HTML] ReadyQueuePage :<|> "tasks" :> QueryParam "status" TaskCore.Status @@ -53,6 +58,14 @@ type API = :<|> "tasks" :> Capture "id" Text :> "accept" :> PostRedirect :<|> "tasks" :> Capture "id" Text :> "reject" :> ReqBody '[FormUrlEncoded] RejectForm :> PostRedirect +data CSS + +instance Accept CSS where + contentType _ = "text/css" + +instance MimeRender CSS LazyText.Text where + mimeRender _ = LazyText.encodeUtf8 + data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task] newtype ReadyQueuePage = ReadyQueuePage [TaskCore.Task] @@ -86,49 +99,77 @@ instance FromForm StatusForm where Just s -> Right (StatusForm s) Nothing -> Left "Invalid status" +pageHead :: (Monad m) => Text -> Lucid.HtmlT m () +pageHead title = + Lucid.head_ <| do + Lucid.title_ (Lucid.toHtml title) + Lucid.meta_ [Lucid.charset_ "utf-8"] + Lucid.meta_ + [ Lucid.name_ "viewport", + Lucid.content_ "width=device-width, initial-scale=1" + ] + Lucid.link_ [Lucid.rel_ "stylesheet", Lucid.href_ "/style.css"] + +statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m () +statusBadge status = + let (cls, label) = case status of + TaskCore.Open -> ("badge badge-open", "Open") + TaskCore.InProgress -> ("badge badge-inprogress", "In Progress") + TaskCore.Review -> ("badge badge-review", "Review") + TaskCore.Approved -> ("badge badge-approved", "Approved") + TaskCore.Done -> ("badge badge-done", "Done") + in Lucid.span_ [Lucid.class_ cls] label + +renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m () +renderTaskCard t = + Lucid.div_ [Lucid.class_ "task-card"] <| do + Lucid.div_ [Lucid.class_ "task-header"] <| do + Lucid.a_ + [ Lucid.class_ "task-id", + Lucid.href_ ("/tasks/" <> TaskCore.taskId t) + ] + (Lucid.toHtml (TaskCore.taskId t)) + statusBadge (TaskCore.taskStatus t) + Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t))) + Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t)) + instance Lucid.ToHtml HomePage where toHtmlRaw = Lucid.toHtml toHtml (HomePage stats readyTasks recentTasks) = Lucid.doctypehtml_ <| do - Lucid.head_ <| do - Lucid.title_ "Jr Dashboard" - Lucid.meta_ [Lucid.charset_ "utf-8"] - Lucid.meta_ - [ Lucid.name_ "viewport", - Lucid.content_ "width=device-width, initial-scale=1" - ] - Lucid.style_ homeStyles + pageHead "Jr Dashboard" Lucid.body_ <| do - Lucid.h1_ "Jr Dashboard" - - Lucid.div_ [Lucid.class_ "actions"] <| do - Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "action-btn"] "View All Tasks" - Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "action-btn action-btn-primary"] "View Ready Queue" - - Lucid.h2_ "Task Status" - Lucid.div_ [Lucid.class_ "stats-grid"] <| do - statCard "Open" (TaskCore.openTasks stats) "badge-open" - statCard "In Progress" (TaskCore.inProgressTasks stats) "badge-inprogress" - statCard "Review" (TaskCore.reviewTasks stats) "badge-review" - statCard "Approved" (TaskCore.approvedTasks stats) "badge-approved" - statCard "Done" (TaskCore.doneTasks stats) "badge-done" - - Lucid.h2_ <| do - "Ready Queue " - Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "ready-link"] - <| Lucid.toHtml ("(" <> tshow (length readyTasks) <> " tasks)") - if null readyTasks - then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks ready for work." - else - Lucid.div_ [Lucid.class_ "task-list"] - <| traverse_ renderTaskCard (take 5 readyTasks) - - Lucid.h2_ "Recent Activity" - if null recentTasks - then Lucid.p_ [Lucid.class_ "empty-msg"] "No recent tasks." - else - Lucid.div_ [Lucid.class_ "task-list"] - <| traverse_ renderTaskCard recentTasks + Lucid.div_ [Lucid.class_ "container"] <| do + Lucid.h1_ "Jr Dashboard" + + Lucid.div_ [Lucid.class_ "actions"] <| do + Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "action-btn"] "View All Tasks" + Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "action-btn action-btn-primary"] "View Ready Queue" + + Lucid.h2_ "Task Status" + Lucid.div_ [Lucid.class_ "stats-grid"] <| do + statCard "Open" (TaskCore.openTasks stats) "badge-open" + statCard "In Progress" (TaskCore.inProgressTasks stats) "badge-inprogress" + statCard "Review" (TaskCore.reviewTasks stats) "badge-review" + statCard "Approved" (TaskCore.approvedTasks stats) "badge-approved" + statCard "Done" (TaskCore.doneTasks stats) "badge-done" + + Lucid.h2_ <| do + "Ready Queue " + Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "ready-link"] + <| Lucid.toHtml ("(" <> tshow (length readyTasks) <> " tasks)") + if null readyTasks + then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks ready for work." + else + Lucid.div_ [Lucid.class_ "task-list"] + <| traverse_ renderTaskCard (take 5 readyTasks) + + Lucid.h2_ "Recent Activity" + if null recentTasks + then Lucid.p_ [Lucid.class_ "empty-msg"] "No recent tasks." + else + Lucid.div_ [Lucid.class_ "task-list"] + <| traverse_ renderTaskCard recentTasks where statCard :: (Monad m) => Text -> Int -> Text -> Lucid.HtmlT m () statCard label count badgeClass = @@ -136,202 +177,69 @@ instance Lucid.ToHtml HomePage where Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml (tshow count)) Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label) - renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m () - renderTaskCard t = - Lucid.div_ [Lucid.class_ "task-card"] <| do - Lucid.div_ [Lucid.class_ "task-header"] <| do - Lucid.a_ - [ Lucid.class_ "task-id", - Lucid.href_ ("/tasks/" <> TaskCore.taskId t) - ] - (Lucid.toHtml (TaskCore.taskId t)) - statusBadge (TaskCore.taskStatus t) - Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t))) - Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t)) - - statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m () - statusBadge status = - let (cls, label) = case status of - TaskCore.Open -> ("badge badge-open", "Open") - TaskCore.InProgress -> ("badge badge-inprogress", "In Progress") - TaskCore.Review -> ("badge badge-review", "Review") - TaskCore.Approved -> ("badge badge-approved", "Approved") - TaskCore.Done -> ("badge badge-done", "Done") - in Lucid.span_ [Lucid.class_ cls] label - -homeStyles :: Text -homeStyles = - "* { box-sizing: border-box; } \ - \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \ - \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 900px; } \ - \h1 { margin: 0 0 16px 0; } \ - \h2 { margin: 24px 0 12px 0; color: #374151; font-size: 18px; } \ - \.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } \ - \.action-btn { display: inline-block; padding: 10px 16px; background: white; \ - \ border: 1px solid #d1d5db; border-radius: 6px; color: #374151; \ - \ text-decoration: none; font-size: 14px; font-weight: 500; } \ - \.action-btn:hover { background: #f9fafb; } \ - \.action-btn-primary { background: #0066cc; color: white; border-color: #0066cc; } \ - \.action-btn-primary:hover { background: #0052a3; } \ - \.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; } \ - \.stat-card { background: white; border-radius: 8px; padding: 16px; text-align: center; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \ - \.stat-count { font-size: 28px; font-weight: 700; } \ - \.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; } \ - \.stat-card.badge-open { border-left: 4px solid #f59e0b; } \ - \.stat-card.badge-open .stat-count { color: #92400e; } \ - \.stat-card.badge-inprogress { border-left: 4px solid #3b82f6; } \ - \.stat-card.badge-inprogress .stat-count { color: #1e40af; } \ - \.stat-card.badge-review { border-left: 4px solid #8b5cf6; } \ - \.stat-card.badge-review .stat-count { color: #6b21a8; } \ - \.stat-card.badge-approved { border-left: 4px solid #10b981; } \ - \.stat-card.badge-approved .stat-count { color: #065f46; } \ - \.stat-card.badge-done { border-left: 4px solid #10b981; } \ - \.stat-card.badge-done .stat-count { color: #065f46; } \ - \.ready-link { font-size: 14px; color: #0066cc; text-decoration: none; } \ - \.ready-link:hover { text-decoration: underline; } \ - \.empty-msg { color: #6b7280; font-style: italic; } \ - \.task-list { display: flex; flex-direction: column; gap: 8px; } \ - \.task-card { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \ - \.task-header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; } \ - \.task-id { font-family: monospace; color: #0066cc; text-decoration: none; \ - \ font-size: 14px; padding: 4px 0; } \ - \.task-id:hover { text-decoration: underline; } \ - \.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; \ - \ font-size: 12px; font-weight: 500; } \ - \.badge-open { background: #fef3c7; color: #92400e; } \ - \.badge-inprogress { background: #dbeafe; color: #1e40af; } \ - \.badge-review { background: #ede9fe; color: #6b21a8; } \ - \.badge-approved { background: #d1fae5; color: #065f46; } \ - \.badge-done { background: #d1fae5; color: #065f46; } \ - \.priority { font-size: 12px; color: #6b7280; } \ - \.task-title { font-size: 16px; margin: 0; }" - instance Lucid.ToHtml ReadyQueuePage where toHtmlRaw = Lucid.toHtml toHtml (ReadyQueuePage tasks) = Lucid.doctypehtml_ <| do - Lucid.head_ <| do - Lucid.title_ "Ready Queue - Jr Web UI" - Lucid.meta_ [Lucid.charset_ "utf-8"] - Lucid.meta_ - [ Lucid.name_ "viewport", - Lucid.content_ "width=device-width, initial-scale=1" - ] - Lucid.style_ readyStyles + pageHead "Ready Queue - Jr" Lucid.body_ <| do - Lucid.p_ <| Lucid.a_ [Lucid.href_ "/"] "← Back to Dashboard" - Lucid.h1_ <| Lucid.toHtml ("Ready Queue (" <> tshow (length tasks) <> " tasks)") - if null tasks - then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks are ready for work." - else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTask tasks - where - readyStyles :: Text - readyStyles = - "* { box-sizing: border-box; } \ - \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \ - \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 900px; } \ - \h1 { margin: 16px 0; } \ - \.count-badge { background: #0066cc; color: white; padding: 4px 10px; \ - \ border-radius: 12px; font-size: 14px; vertical-align: middle; } \ - \.empty-msg { color: #6b7280; font-style: italic; } \ - \.task-list { display: flex; flex-direction: column; gap: 8px; } \ - \.task-card { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \ - \.task-header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; } \ - \.task-id { font-family: monospace; color: #0066cc; text-decoration: none; \ - \ font-size: 14px; padding: 4px 0; } \ - \.task-id:hover { text-decoration: underline; } \ - \.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; \ - \ font-size: 12px; font-weight: 500; } \ - \.badge-open { background: #fef3c7; color: #92400e; } \ - \.badge-inprogress { background: #dbeafe; color: #1e40af; } \ - \.badge-review { background: #ede9fe; color: #6b21a8; } \ - \.badge-approved { background: #d1fae5; color: #065f46; } \ - \.badge-done { background: #d1fae5; color: #065f46; } \ - \.priority { font-size: 12px; color: #6b7280; } \ - \.task-title { font-size: 16px; margin: 0; }" - - renderTask :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m () - renderTask t = - Lucid.div_ [Lucid.class_ "task-card"] <| do - Lucid.div_ [Lucid.class_ "task-header"] <| do - Lucid.a_ - [ Lucid.class_ "task-id", - Lucid.href_ ("/tasks/" <> TaskCore.taskId t) - ] - (Lucid.toHtml (TaskCore.taskId t)) - statusBadge (TaskCore.taskStatus t) - Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t))) - Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t)) - - statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m () - statusBadge status = - let (cls, label) = case status of - TaskCore.Open -> ("badge badge-open", "Open") - TaskCore.InProgress -> ("badge badge-inprogress", "In Progress") - TaskCore.Review -> ("badge badge-review", "Review") - TaskCore.Approved -> ("badge badge-approved", "Approved") - TaskCore.Done -> ("badge badge-done", "Done") - in Lucid.span_ [Lucid.class_ cls] label + Lucid.div_ [Lucid.class_ "container"] <| do + Lucid.p_ [Lucid.class_ "back-link"] <| Lucid.a_ [Lucid.href_ "/"] "← Back to Dashboard" + Lucid.h1_ <| Lucid.toHtml ("Ready Queue (" <> tshow (length tasks) <> " tasks)") + if null tasks + then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks are ready for work." + else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard tasks instance Lucid.ToHtml TaskListPage where toHtmlRaw = Lucid.toHtml toHtml (TaskListPage tasks filters) = Lucid.doctypehtml_ <| do - Lucid.head_ <| do - Lucid.title_ "Tasks - Jr Web UI" - Lucid.meta_ [Lucid.charset_ "utf-8"] - Lucid.meta_ - [ Lucid.name_ "viewport", - Lucid.content_ "width=device-width, initial-scale=1" - ] - Lucid.style_ styles + pageHead "Tasks - Jr" Lucid.body_ <| do - Lucid.p_ <| Lucid.a_ [Lucid.href_ "/"] "← Back to Dashboard" - Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")") - - Lucid.div_ [Lucid.class_ "filter-form"] <| do - Lucid.form_ [Lucid.method_ "GET", Lucid.action_ "/tasks"] <| do - Lucid.div_ [Lucid.class_ "filter-row"] <| do - Lucid.div_ [Lucid.class_ "filter-group"] <| do - Lucid.label_ [Lucid.for_ "status"] "Status:" - Lucid.select_ [Lucid.name_ "status", Lucid.id_ "status", Lucid.class_ "filter-select"] <| do - Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterStatus filters)) "All" - statusFilterOption TaskCore.Open (filterStatus filters) - statusFilterOption TaskCore.InProgress (filterStatus filters) - statusFilterOption TaskCore.Review (filterStatus filters) - statusFilterOption TaskCore.Approved (filterStatus filters) - statusFilterOption TaskCore.Done (filterStatus filters) - - Lucid.div_ [Lucid.class_ "filter-group"] <| do - Lucid.label_ [Lucid.for_ "priority"] "Priority:" - Lucid.select_ [Lucid.name_ "priority", Lucid.id_ "priority", Lucid.class_ "filter-select"] <| do - Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterPriority filters)) "All" - priorityFilterOption TaskCore.P0 (filterPriority filters) - priorityFilterOption TaskCore.P1 (filterPriority filters) - priorityFilterOption TaskCore.P2 (filterPriority filters) - priorityFilterOption TaskCore.P3 (filterPriority filters) - priorityFilterOption TaskCore.P4 (filterPriority filters) - - Lucid.div_ [Lucid.class_ "filter-group"] <| do - Lucid.label_ [Lucid.for_ "namespace"] "Namespace:" - Lucid.input_ - [ Lucid.type_ "text", - Lucid.name_ "namespace", - Lucid.id_ "namespace", - Lucid.class_ "filter-input", - Lucid.placeholder_ "e.g. Omni/Jr", - Lucid.value_ (fromMaybe "" (filterNamespace filters)) - ] + Lucid.div_ [Lucid.class_ "container"] <| do + Lucid.p_ [Lucid.class_ "back-link"] <| Lucid.a_ [Lucid.href_ "/"] "← Back to Dashboard" + Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")") + + Lucid.div_ [Lucid.class_ "filter-form"] <| do + Lucid.form_ [Lucid.method_ "GET", Lucid.action_ "/tasks"] <| do + Lucid.div_ [Lucid.class_ "filter-row"] <| do + Lucid.div_ [Lucid.class_ "filter-group"] <| do + Lucid.label_ [Lucid.for_ "status"] "Status:" + Lucid.select_ [Lucid.name_ "status", Lucid.id_ "status", Lucid.class_ "filter-select"] <| do + Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterStatus filters)) "All" + statusFilterOption TaskCore.Open (filterStatus filters) + statusFilterOption TaskCore.InProgress (filterStatus filters) + statusFilterOption TaskCore.Review (filterStatus filters) + statusFilterOption TaskCore.Approved (filterStatus filters) + statusFilterOption TaskCore.Done (filterStatus filters) + + Lucid.div_ [Lucid.class_ "filter-group"] <| do + Lucid.label_ [Lucid.for_ "priority"] "Priority:" + Lucid.select_ [Lucid.name_ "priority", Lucid.id_ "priority", Lucid.class_ "filter-select"] <| do + Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterPriority filters)) "All" + priorityFilterOption TaskCore.P0 (filterPriority filters) + priorityFilterOption TaskCore.P1 (filterPriority filters) + priorityFilterOption TaskCore.P2 (filterPriority filters) + priorityFilterOption TaskCore.P3 (filterPriority filters) + priorityFilterOption TaskCore.P4 (filterPriority filters) + + Lucid.div_ [Lucid.class_ "filter-group"] <| do + Lucid.label_ [Lucid.for_ "namespace"] "Namespace:" + Lucid.input_ + [ Lucid.type_ "text", + Lucid.name_ "namespace", + Lucid.id_ "namespace", + Lucid.class_ "filter-input", + Lucid.placeholder_ "e.g. Omni/Jr", + Lucid.value_ (fromMaybe "" (filterNamespace filters)) + ] - Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter" - Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "clear-btn"] "Clear" + Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter" + Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "clear-btn"] "Clear" - if null tasks - then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters." - else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTask tasks + if null tasks + then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters." + else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard tasks where maybeSelected :: (Eq a) => Maybe a -> Maybe a -> [Lucid.Attribute] maybeSelected opt current = [Lucid.selected_ "selected" | opt == current] @@ -346,181 +254,108 @@ instance Lucid.ToHtml TaskListPage where let attrs = [Lucid.value_ (tshow p)] <> [Lucid.selected_ "selected" | Just p == current] in Lucid.option_ attrs (Lucid.toHtml (tshow p)) - styles :: Text - styles = - "* { box-sizing: border-box; } \ - \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \ - \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 900px; } \ - \h1 { margin: 16px 0; } \ - \.filter-form { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px; } \ - \.filter-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; } \ - \.filter-group { display: flex; flex-direction: column; gap: 4px; } \ - \.filter-group label { font-size: 12px; color: #6b7280; font-weight: 500; } \ - \.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 4px; \ - \ font-size: 14px; min-width: 120px; } \ - \.filter-input { min-width: 150px; } \ - \.filter-btn { padding: 8px 16px; background: #0066cc; color: white; border: none; \ - \ border-radius: 4px; font-size: 14px; cursor: pointer; } \ - \.filter-btn:hover { background: #0052a3; } \ - \.clear-btn { padding: 8px 16px; background: #6b7280; color: white; border: none; \ - \ border-radius: 4px; font-size: 14px; cursor: pointer; text-decoration: none; } \ - \.clear-btn:hover { background: #4b5563; } \ - \.empty-msg { color: #6b7280; font-style: italic; } \ - \.task-list { display: flex; flex-direction: column; gap: 8px; } \ - \.task-card { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \ - \.task-header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; } \ - \.task-id { font-family: monospace; color: #0066cc; text-decoration: none; \ - \ font-size: 14px; padding: 4px 0; } \ - \.task-id:hover { text-decoration: underline; } \ - \.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; \ - \ font-size: 12px; font-weight: 500; } \ - \.badge-open { background: #fef3c7; color: #92400e; } \ - \.badge-inprogress { background: #dbeafe; color: #1e40af; } \ - \.badge-review { background: #ede9fe; color: #6b21a8; } \ - \.badge-approved { background: #d1fae5; color: #065f46; } \ - \.badge-done { background: #d1fae5; color: #065f46; } \ - \.priority { font-size: 12px; color: #6b7280; } \ - \.task-title { font-size: 16px; margin: 0; }" - - renderTask :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m () - renderTask t = - Lucid.div_ [Lucid.class_ "task-card"] <| do - Lucid.div_ [Lucid.class_ "task-header"] <| do - Lucid.a_ - [ Lucid.class_ "task-id", - Lucid.href_ ("/tasks/" <> TaskCore.taskId t) - ] - (Lucid.toHtml (TaskCore.taskId t)) - statusBadge (TaskCore.taskStatus t) - Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t))) - Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t)) - - statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m () - statusBadge status = - let (cls, label) = case status of - TaskCore.Open -> ("badge badge-open", "Open") - TaskCore.InProgress -> ("badge badge-inprogress", "In Progress") - TaskCore.Review -> ("badge badge-review", "Review") - TaskCore.Approved -> ("badge badge-approved", "Approved") - TaskCore.Done -> ("badge badge-done", "Done") - in Lucid.span_ [Lucid.class_ cls] label - instance Lucid.ToHtml TaskDetailPage where toHtmlRaw = Lucid.toHtml toHtml (TaskDetailNotFound tid) = Lucid.doctypehtml_ <| do - Lucid.head_ <| do - Lucid.title_ "Task Not Found - Jr Web UI" - Lucid.meta_ [Lucid.charset_ "utf-8"] - Lucid.meta_ - [ Lucid.name_ "viewport", - Lucid.content_ "width=device-width, initial-scale=1" - ] - Lucid.style_ detailStyles + pageHead "Task Not Found - Jr" Lucid.body_ <| do - Lucid.h1_ "Task Not Found" - Lucid.p_ <| do - "The task " - Lucid.code_ (Lucid.toHtml tid) - " could not be found." - Lucid.p_ <| Lucid.a_ [Lucid.href_ "/tasks"] "← Back to Tasks" + Lucid.div_ [Lucid.class_ "container"] <| do + Lucid.h1_ "Task Not Found" + Lucid.p_ <| do + "The task " + Lucid.code_ (Lucid.toHtml tid) + " could not be found." + Lucid.p_ [Lucid.class_ "back-link"] <| Lucid.a_ [Lucid.href_ "/tasks"] "← Back to Tasks" toHtml (TaskDetailFound task allTasks) = Lucid.doctypehtml_ <| do - Lucid.head_ <| do - Lucid.title_ <| Lucid.toHtml (TaskCore.taskId task <> " - Jr Web UI") - Lucid.meta_ [Lucid.charset_ "utf-8"] - Lucid.meta_ - [ Lucid.name_ "viewport", - Lucid.content_ "width=device-width, initial-scale=1" - ] - Lucid.style_ detailStyles + pageHead (TaskCore.taskId task <> " - Jr") Lucid.body_ <| do - Lucid.p_ <| Lucid.a_ [Lucid.href_ "/tasks"] "← Back to Tasks" - - Lucid.h1_ <| Lucid.toHtml (TaskCore.taskTitle task) - - Lucid.div_ [Lucid.class_ "task-detail"] <| do - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "ID:" - Lucid.code_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskId task)) - - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Type:" - Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.taskType task))) - - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Status:" - Lucid.span_ [Lucid.class_ "detail-value"] <| statusBadge (TaskCore.taskStatus task) - - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Priority:" - Lucid.span_ [Lucid.class_ "detail-value"] <| do - Lucid.toHtml (tshow (TaskCore.taskPriority task)) - Lucid.span_ [Lucid.class_ "priority-desc"] (Lucid.toHtml (priorityDesc (TaskCore.taskPriority task))) - - case TaskCore.taskNamespace task of - Nothing -> pure () - Just ns -> - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Namespace:" - Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml ns) - - case TaskCore.taskParent task of - Nothing -> pure () - Just pid -> - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Parent:" - Lucid.a_ [Lucid.href_ ("/tasks/" <> pid), Lucid.class_ "detail-value task-link"] (Lucid.toHtml pid) - - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Created:" - Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.taskCreatedAt task))) - - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Updated:" - Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.taskUpdatedAt task))) - - let deps = TaskCore.taskDependencies task - unless (null deps) <| do - Lucid.div_ [Lucid.class_ "detail-section"] <| do - Lucid.h3_ "Dependencies" - Lucid.ul_ [Lucid.class_ "dep-list"] <| do - traverse_ renderDependency deps - - case TaskCore.taskDescription task of - Nothing -> pure () - Just desc -> + Lucid.div_ [Lucid.class_ "container"] <| do + Lucid.p_ [Lucid.class_ "back-link"] <| Lucid.a_ [Lucid.href_ "/tasks"] "← Back to Tasks" + + Lucid.h1_ <| Lucid.toHtml (TaskCore.taskTitle task) + + Lucid.div_ [Lucid.class_ "task-detail"] <| do + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "ID:" + Lucid.code_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskId task)) + + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Type:" + Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.taskType task))) + + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Status:" + Lucid.span_ [Lucid.class_ "detail-value"] <| statusBadge (TaskCore.taskStatus task) + + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Priority:" + Lucid.span_ [Lucid.class_ "detail-value"] <| do + Lucid.toHtml (tshow (TaskCore.taskPriority task)) + Lucid.span_ [Lucid.class_ "priority-desc"] (Lucid.toHtml (priorityDesc (TaskCore.taskPriority task))) + + case TaskCore.taskNamespace task of + Nothing -> pure () + Just ns -> + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Namespace:" + Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml ns) + + case TaskCore.taskParent task of + Nothing -> pure () + Just pid -> + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Parent:" + Lucid.a_ [Lucid.href_ ("/tasks/" <> pid), Lucid.class_ "detail-value task-link"] (Lucid.toHtml pid) + + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Created:" + Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.taskCreatedAt task))) + + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Updated:" + Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.taskUpdatedAt task))) + + let deps = TaskCore.taskDependencies task + unless (null deps) <| do + Lucid.div_ [Lucid.class_ "detail-section"] <| do + Lucid.h3_ "Dependencies" + Lucid.ul_ [Lucid.class_ "dep-list"] <| do + traverse_ renderDependency deps + + case TaskCore.taskDescription task of + Nothing -> pure () + Just desc -> + Lucid.div_ [Lucid.class_ "detail-section"] <| do + Lucid.h3_ "Description" + Lucid.pre_ [Lucid.class_ "description"] (Lucid.toHtml desc) + + let children = filter (maybe False (TaskCore.matchesId (TaskCore.taskId task)) <. TaskCore.taskParent) allTasks + unless (null children) <| do Lucid.div_ [Lucid.class_ "detail-section"] <| do - Lucid.h3_ "Description" - Lucid.pre_ [Lucid.class_ "description"] (Lucid.toHtml desc) - - let children = filter (maybe False (TaskCore.matchesId (TaskCore.taskId task)) <. TaskCore.taskParent) allTasks - unless (null children) <| do - Lucid.div_ [Lucid.class_ "detail-section"] <| do - Lucid.h3_ "Child Tasks" - Lucid.ul_ [Lucid.class_ "child-list"] <| do - traverse_ renderChild children - - when (TaskCore.taskStatus task == TaskCore.Review) <| do - Lucid.div_ [Lucid.class_ "review-link-section"] <| do - Lucid.a_ - [ Lucid.href_ ("/tasks/" <> TaskCore.taskId task <> "/review"), - Lucid.class_ "review-link-btn" - ] - "Review This Task" - - Lucid.div_ [Lucid.class_ "status-form"] <| do - Lucid.h3_ "Update Status" - Lucid.form_ [Lucid.method_ "POST", Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/status")] <| do - Lucid.select_ [Lucid.name_ "status", Lucid.class_ "status-select"] <| do - statusOption TaskCore.Open (TaskCore.taskStatus task) - statusOption TaskCore.InProgress (TaskCore.taskStatus task) - statusOption TaskCore.Review (TaskCore.taskStatus task) - statusOption TaskCore.Approved (TaskCore.taskStatus task) - statusOption TaskCore.Done (TaskCore.taskStatus task) - Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "submit-btn"] "Submit" + Lucid.h3_ "Child Tasks" + Lucid.ul_ [Lucid.class_ "child-list"] <| do + traverse_ renderChild children + + when (TaskCore.taskStatus task == TaskCore.Review) <| do + Lucid.div_ [Lucid.class_ "review-link-section"] <| do + Lucid.a_ + [ Lucid.href_ ("/tasks/" <> TaskCore.taskId task <> "/review"), + Lucid.class_ "review-link-btn" + ] + "Review This Task" + + Lucid.div_ [Lucid.class_ "status-form"] <| do + Lucid.h3_ "Update Status" + Lucid.form_ [Lucid.method_ "POST", Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/status")] <| do + Lucid.select_ [Lucid.name_ "status", Lucid.class_ "status-select"] <| do + statusOption TaskCore.Open (TaskCore.taskStatus task) + statusOption TaskCore.InProgress (TaskCore.taskStatus task) + statusOption TaskCore.Review (TaskCore.taskStatus task) + statusOption TaskCore.Approved (TaskCore.taskStatus task) + statusOption TaskCore.Done (TaskCore.taskStatus task) + Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "submit-btn"] "Submit" where priorityDesc :: TaskCore.Priority -> Text priorityDesc p = case p of @@ -530,19 +365,9 @@ instance Lucid.ToHtml TaskDetailPage where TaskCore.P3 -> " (Low)" TaskCore.P4 -> " (Backlog)" - statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m () - statusBadge status = - let (cls, label) = case status of - TaskCore.Open -> ("badge badge-open", "Open") - TaskCore.InProgress -> ("badge badge-inprogress", "In Progress") - TaskCore.Review -> ("badge badge-review", "Review") - TaskCore.Approved -> ("badge badge-approved", "Approved") - TaskCore.Done -> ("badge badge-done", "Done") - in Lucid.span_ [Lucid.class_ cls] label - statusOption :: (Monad m) => TaskCore.Status -> TaskCore.Status -> Lucid.HtmlT m () statusOption opt current = - let attrs = if opt == current then [Lucid.value_ (tshow opt), Lucid.selected_ "selected"] else [Lucid.value_ (tshow opt)] + let attrs = [Lucid.value_ (tshow opt)] <> [Lucid.selected_ "selected" | opt == current] in Lucid.option_ attrs (Lucid.toHtml (tshow opt)) renderDependency :: (Monad m) => TaskCore.Dependency -> Lucid.HtmlT m () @@ -558,192 +383,83 @@ instance Lucid.ToHtml TaskDetailPage where Lucid.span_ [Lucid.class_ "child-title"] <| Lucid.toHtml (" - " <> TaskCore.taskTitle child) Lucid.span_ [Lucid.class_ "child-status"] <| Lucid.toHtml (" [" <> tshow (TaskCore.taskStatus child) <> "]") -detailStyles :: Text -detailStyles = - "* { box-sizing: border-box; } \ - \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \ - \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 800px; } \ - \h1 { margin: 16px 0; } \ - \h3 { margin: 16px 0 8px 0; color: #374151; } \ - \.task-detail { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \ - \.detail-row { display: flex; padding: 8px 0; border-bottom: 1px solid #e5e7eb; } \ - \.detail-row:last-child { border-bottom: none; } \ - \.detail-label { font-weight: 600; width: 120px; color: #6b7280; } \ - \.detail-value { flex: 1; } \ - \.detail-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid #e5e7eb; } \ - \.task-link { color: #0066cc; text-decoration: none; font-family: monospace; } \ - \.task-link:hover { text-decoration: underline; } \ - \.dep-list, .child-list { margin: 8px 0; padding-left: 20px; } \ - \.dep-list li, .child-list li { margin: 4px 0; } \ - \.dep-type { color: #6b7280; font-size: 14px; } \ - \.child-title { color: #374151; } \ - \.child-status { color: #6b7280; font-size: 14px; } \ - \.description { background: #f9fafb; padding: 12px; border-radius: 4px; \ - \ font-family: monospace; font-size: 14px; white-space: pre-wrap; margin: 0; } \ - \.priority-desc { color: #6b7280; margin-left: 4px; } \ - \.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; \ - \ font-size: 12px; font-weight: 500; } \ - \.badge-open { background: #fef3c7; color: #92400e; } \ - \.badge-inprogress { background: #dbeafe; color: #1e40af; } \ - \.badge-review { background: #ede9fe; color: #6b21a8; } \ - \.badge-approved { background: #d1fae5; color: #065f46; } \ - \.badge-done { background: #d1fae5; color: #065f46; } \ - \.status-form { margin-top: 24px; background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); } \ - \.status-select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 4px; \ - \ font-size: 14px; margin-right: 8px; } \ - \.submit-btn { padding: 8px 16px; background: #0066cc; color: white; border: none; \ - \ border-radius: 4px; font-size: 14px; cursor: pointer; } \ - \.submit-btn:hover { background: #0052a3; } \ - \.review-link-section { margin: 16px 0; } \ - \.review-link-btn { display: inline-block; padding: 12px 24px; background: #8b5cf6; \ - \ color: white; text-decoration: none; border-radius: 6px; \ - \ font-size: 16px; font-weight: 500; } \ - \.review-link-btn:hover { background: #7c3aed; }" - instance Lucid.ToHtml TaskReviewPage where toHtmlRaw = Lucid.toHtml toHtml (ReviewPageNotFound tid) = Lucid.doctypehtml_ <| do - Lucid.head_ <| do - Lucid.title_ "Task Not Found - Jr Review" - Lucid.meta_ [Lucid.charset_ "utf-8"] - Lucid.meta_ - [ Lucid.name_ "viewport", - Lucid.content_ "width=device-width, initial-scale=1" - ] - Lucid.style_ reviewStyles + pageHead "Task Not Found - Jr Review" Lucid.body_ <| do - Lucid.h1_ "Task Not Found" - Lucid.p_ <| do - "The task " - Lucid.code_ (Lucid.toHtml tid) - " could not be found." - Lucid.p_ <| Lucid.a_ [Lucid.href_ "/tasks"] "<- Back to Tasks" + Lucid.div_ [Lucid.class_ "container"] <| do + Lucid.h1_ "Task Not Found" + Lucid.p_ <| do + "The task " + Lucid.code_ (Lucid.toHtml tid) + " could not be found." + Lucid.p_ [Lucid.class_ "back-link"] <| Lucid.a_ [Lucid.href_ "/tasks"] "← Back to Tasks" toHtml (ReviewPageFound task reviewInfo) = Lucid.doctypehtml_ <| do - Lucid.head_ <| do - Lucid.title_ <| Lucid.toHtml ("Review: " <> TaskCore.taskId task <> " - Jr") - Lucid.meta_ [Lucid.charset_ "utf-8"] - Lucid.meta_ - [ Lucid.name_ "viewport", - Lucid.content_ "width=device-width, initial-scale=1" - ] - Lucid.style_ reviewStyles + pageHead ("Review: " <> TaskCore.taskId task <> " - Jr") Lucid.body_ <| do - Lucid.p_ <| Lucid.a_ [Lucid.href_ ("/tasks/" <> TaskCore.taskId task)] "<- Back to Task" - - Lucid.h1_ "Review Task" - - Lucid.div_ [Lucid.class_ "task-summary"] <| do - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "ID:" - Lucid.code_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskId task)) - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Title:" - Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskTitle task)) - Lucid.div_ [Lucid.class_ "detail-row"] <| do - Lucid.span_ [Lucid.class_ "detail-label"] "Status:" - Lucid.span_ [Lucid.class_ "detail-value"] <| statusBadge (TaskCore.taskStatus task) - - case reviewInfo of - ReviewNoCommit -> do - Lucid.div_ [Lucid.class_ "no-commit-msg"] <| do - Lucid.h3_ "No Commit Found" - Lucid.p_ "No commit with this task ID was found in the git history." - Lucid.p_ "The worker may not have completed yet, or the commit message doesn't include the task ID." - ReviewMergeConflict commitSha conflictFiles -> do - Lucid.div_ [Lucid.class_ "conflict-warning"] <| do - Lucid.h3_ "Merge Conflict Detected" - Lucid.p_ <| do - "Commit " - Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha)) - " cannot be cleanly merged." - Lucid.p_ "Conflicting files:" - Lucid.ul_ <| traverse_ (Lucid.li_ <. Lucid.toHtml) conflictFiles - ReviewReady commitSha diffText -> do - Lucid.div_ [Lucid.class_ "diff-section"] <| do - Lucid.h3_ <| do - "Commit: " - Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha)) - Lucid.pre_ [Lucid.class_ "diff-block"] (Lucid.toHtml diffText) - - Lucid.div_ [Lucid.class_ "review-actions"] <| do - Lucid.form_ - [ Lucid.method_ "POST", - Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/accept"), - Lucid.class_ "inline-form" - ] - <| do - Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "accept-btn"] "Accept" + Lucid.div_ [Lucid.class_ "container"] <| do + Lucid.p_ [Lucid.class_ "back-link"] <| Lucid.a_ [Lucid.href_ ("/tasks/" <> TaskCore.taskId task)] "← Back to Task" + + Lucid.h1_ "Review Task" + + Lucid.div_ [Lucid.class_ "task-summary"] <| do + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "ID:" + Lucid.code_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskId task)) + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Title:" + Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskTitle task)) + Lucid.div_ [Lucid.class_ "detail-row"] <| do + Lucid.span_ [Lucid.class_ "detail-label"] "Status:" + Lucid.span_ [Lucid.class_ "detail-value"] <| statusBadge (TaskCore.taskStatus task) + + case reviewInfo of + ReviewNoCommit -> + Lucid.div_ [Lucid.class_ "no-commit-msg"] <| do + Lucid.h3_ "No Commit Found" + Lucid.p_ "No commit with this task ID was found in the git history." + Lucid.p_ "The worker may not have completed yet, or the commit message doesn't include the task ID." + ReviewMergeConflict commitSha conflictFiles -> + Lucid.div_ [Lucid.class_ "conflict-warning"] <| do + Lucid.h3_ "Merge Conflict Detected" + Lucid.p_ <| do + "Commit " + Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha)) + " cannot be cleanly merged." + Lucid.p_ "Conflicting files:" + Lucid.ul_ <| traverse_ (Lucid.li_ <. Lucid.toHtml) conflictFiles + ReviewReady commitSha diffText -> do + Lucid.div_ [Lucid.class_ "diff-section"] <| do + Lucid.h3_ <| do + "Commit: " + Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha)) + Lucid.pre_ [Lucid.class_ "diff-block"] (Lucid.toHtml diffText) + + Lucid.div_ [Lucid.class_ "review-actions"] <| do + Lucid.form_ + [ Lucid.method_ "POST", + Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/accept"), + Lucid.class_ "inline-form" + ] + <| do + Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "accept-btn"] "Accept" - Lucid.form_ - [ Lucid.method_ "POST", - Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/reject"), - Lucid.class_ "reject-form" - ] - <| do - Lucid.textarea_ - [ Lucid.name_ "notes", - Lucid.class_ "reject-notes", - Lucid.placeholder_ "Rejection notes (optional)" - ] - "" - Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "reject-btn"] "Reject" - where - statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m () - statusBadge status = - let (cls, label) = case status of - TaskCore.Open -> ("badge badge-open", "Open") - TaskCore.InProgress -> ("badge badge-inprogress", "In Progress") - TaskCore.Review -> ("badge badge-review", "Review") - TaskCore.Approved -> ("badge badge-approved", "Approved") - TaskCore.Done -> ("badge badge-done", "Done") - in Lucid.span_ [Lucid.class_ cls] label - -reviewStyles :: Text -reviewStyles = - "* { box-sizing: border-box; } \ - \body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; \ - \ margin: 0; padding: 16px; background: #f5f5f5; max-width: 1000px; } \ - \h1 { margin: 16px 0; } \ - \h3 { margin: 16px 0 8px 0; color: #374151; } \ - \.task-summary { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px; } \ - \.detail-row { display: flex; padding: 8px 0; border-bottom: 1px solid #e5e7eb; } \ - \.detail-row:last-child { border-bottom: none; } \ - \.detail-label { font-weight: 600; width: 100px; color: #6b7280; } \ - \.detail-value { flex: 1; } \ - \.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; \ - \ font-size: 12px; font-weight: 500; } \ - \.badge-open { background: #fef3c7; color: #92400e; } \ - \.badge-inprogress { background: #dbeafe; color: #1e40af; } \ - \.badge-review { background: #ede9fe; color: #6b21a8; } \ - \.badge-approved { background: #d1fae5; color: #065f46; } \ - \.badge-done { background: #d1fae5; color: #065f46; } \ - \.no-commit-msg { background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; \ - \ padding: 16px; margin: 16px 0; } \ - \.conflict-warning { background: #f8d7da; border: 1px solid #dc3545; border-radius: 8px; \ - \ padding: 16px; margin: 16px 0; } \ - \.diff-section { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin: 16px 0; } \ - \.diff-block { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 4px; \ - \ font-family: 'SF Mono', Monaco, 'Courier New', monospace; font-size: 13px; \ - \ overflow-x: auto; white-space: pre; margin: 0; max-height: 600px; overflow-y: auto; } \ - \.review-actions { background: white; border-radius: 8px; padding: 16px; \ - \ box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; gap: 16px; \ - \ align-items: flex-start; flex-wrap: wrap; } \ - \.inline-form { display: inline-block; } \ - \.reject-form { display: flex; gap: 8px; flex: 1; min-width: 300px; } \ - \.reject-notes { flex: 1; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px; \ - \ font-size: 14px; resize: vertical; min-height: 38px; } \ - \.accept-btn { padding: 10px 24px; background: #10b981; color: white; border: none; \ - \ border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; } \ - \.accept-btn:hover { background: #059669; } \ - \.reject-btn { padding: 10px 24px; background: #ef4444; color: white; border: none; \ - \ border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; } \ - \.reject-btn:hover { background: #dc2626; }" + Lucid.form_ + [ Lucid.method_ "POST", + Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/reject"), + Lucid.class_ "reject-form" + ] + <| do + Lucid.textarea_ + [ Lucid.name_ "notes", + Lucid.class_ "reject-notes", + Lucid.placeholder_ "Rejection notes (optional)" + ] + "" + Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "reject-btn"] "Reject" api :: Proxy API api = Proxy @@ -751,6 +467,7 @@ api = Proxy server :: Server API server = homeHandler + :<|> styleHandler :<|> readyQueueHandler :<|> taskListHandler :<|> taskDetailHandler @@ -759,6 +476,9 @@ server = :<|> taskAcceptHandler :<|> taskRejectHandler where + styleHandler :: Servant.Handler LazyText.Text + styleHandler = pure Style.css + homeHandler :: Servant.Handler HomePage homeHandler = do stats <- liftIO <| TaskCore.getTaskStats Nothing diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs new file mode 100644 index 0000000..e2377b5 --- /dev/null +++ b/Omni/Jr/Web/Style.hs @@ -0,0 +1,510 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- : dep clay +module Omni.Jr.Web.Style + ( css, + statusBadgeClass, + priorityBadgeClass, + ) +where + +import Alpha hiding (wrap, (**), (|>)) +import Clay +import qualified Clay.Flexbox as Flexbox +import qualified Clay.Media as Media +import qualified Clay.Stylesheet as Stylesheet +import qualified Data.List.NonEmpty as NE +import qualified Data.Text.Lazy as LazyText + +css :: LazyText.Text +css = render stylesheet + +stylesheet :: Css +stylesheet = do + baseStyles + layoutStyles + navigationStyles + cardStyles + statusBadges + buttonStyles + formStyles + responsiveStyles + darkModeStyles + +baseStyles :: Css +baseStyles = do + star ? boxSizing borderBox + html <> body ? do + margin (px 0) (px 0) (px 0) (px 0) + padding (px 0) (px 0) (px 0) (px 0) + body ? do + fontFamily + [ "-apple-system", + "BlinkMacSystemFont", + "Segoe UI", + "Roboto", + "Helvetica Neue", + "Arial", + "Noto Sans", + "sans-serif" + ] + [sansSerif] + fontSize (px 16) + lineHeight (em 1.5) + color "#1f2937" + backgroundColor "#f3f4f6" + minHeight (vh 100) + "h1" ? do + fontSize (px 24) + fontWeight bold + margin (px 0) (px 0) (em 0.5) (px 0) + "h2" ? do + fontSize (px 18) + fontWeight (weight 600) + color "#374151" + margin (em 1.5) (px 0) (em 0.75) (px 0) + "h3" ? do + fontSize (px 16) + fontWeight (weight 600) + color "#374151" + margin (em 1) (px 0) (em 0.5) (px 0) + a ? do + color "#0066cc" + textDecoration none + a # hover ? textDecoration underline + code ? do + fontFamily ["SF Mono", "Monaco", "Consolas", "monospace"] [monospace] + fontSize (em 0.9) + backgroundColor "#f3f4f6" + padding (px 2) (px 6) (px 2) (px 6) + borderRadius (px 4) (px 4) (px 4) (px 4) + pre ? do + fontFamily ["SF Mono", "Monaco", "Consolas", "monospace"] [monospace] + fontSize (px 13) + backgroundColor "#1e1e1e" + color "#d4d4d4" + padding (px 16) (px 16) (px 16) (px 16) + borderRadius (px 6) (px 6) (px 6) (px 6) + overflow auto + whiteSpace preWrap + maxHeight (px 500) + +layoutStyles :: Css +layoutStyles = do + ".container" ? do + width (pct 100) + maxWidth (px 900) + margin (px 0) auto (px 0) auto + padding (px 16) (px 16) (px 16) (px 16) + main_ ? do + Stylesheet.key "flex" ("1 0 auto" :: Text) + ".page-content" ? do + padding (px 0) (px 0) (px 0) (px 0) + ".stats-grid" ? do + display grid + Stylesheet.key "grid-template-columns" ("repeat(auto-fit, minmax(100px, 1fr))" :: Text) + Stylesheet.key "gap" ("12px" :: Text) + ".task-list" ? do + display flex + flexDirection column + Stylesheet.key "gap" ("8px" :: Text) + ".detail-row" ? do + display flex + flexWrap Flexbox.wrap + padding (px 10) (px 0) (px 10) (px 0) + borderBottom (px 1) solid "#e5e7eb" + ".detail-row" # lastChild ? borderBottom (px 0) none transparent + ".detail-label" ? do + fontWeight (weight 600) + width (px 120) + color "#6b7280" + minWidth (px 100) + ".detail-value" ? do + Stylesheet.key "flex" ("1" :: Text) + minWidth (px 0) + ".detail-section" ? do + marginTop (em 1) + paddingTop (em 1) + borderTop (px 1) solid "#e5e7eb" + ".dep-list" <> ".child-list" ? do + margin (px 8) (px 0) (px 8) (px 0) + paddingLeft (px 24) + (".dep-list" ** li) <> (".child-list" ** li) ? margin (px 6) (px 0) (px 6) (px 0) + ".dep-type" <> ".child-status" ? do + color "#6b7280" + fontSize (px 13) + ".child-title" ? color "#374151" + ".priority-desc" ? do + color "#6b7280" + marginLeft (px 4) + +navigationStyles :: Css +navigationStyles = do + header ? do + backgroundColor white + padding (px 12) (px 16) (px 12) (px 16) + boxShadow (NE.singleton (bsColor (rgba 0 0 0 0.08) (shadow (px 0) (px 2)))) + marginBottom (px 16) + ".nav-content" ? do + maxWidth (px 900) + margin (px 0) auto (px 0) auto + display flex + alignItems center + justifyContent spaceBetween + flexWrap Flexbox.wrap + Stylesheet.key "gap" ("12px" :: Text) + ".nav-brand" ? do + fontSize (px 20) + fontWeight bold + color "#1f2937" + textDecoration none + ".nav-brand" # hover ? textDecoration none + ".nav-links" ? do + display flex + Stylesheet.key "gap" ("8px" :: Text) + flexWrap Flexbox.wrap + ".back-link" ? do + display inlineBlock + marginBottom (em 0.75) + fontSize (px 14) + ".actions" ? do + display flex + flexWrap Flexbox.wrap + Stylesheet.key "gap" ("8px" :: Text) + marginBottom (px 16) + +cardStyles :: Css +cardStyles = do + ".card" + <> ".task-card" + <> ".stat-card" + <> ".task-detail" + <> ".task-summary" + <> ".filter-form" + <> ".status-form" + <> ".diff-section" + <> ".review-actions" + ? do + backgroundColor white + borderRadius (px 8) (px 8) (px 8) (px 8) + padding (px 16) (px 16) (px 16) (px 16) + boxShadow (NE.singleton (bsColor (rgba 0 0 0 0.1) (shadow (px 0) (px 1)))) + ".stat-card" ? textAlign center + ".stat-count" ? do + fontSize (px 28) + fontWeight bold + ".stat-label" ? do + fontSize (px 12) + color "#6b7280" + marginTop (px 4) + ".stat-card.badge-open" ? do + borderLeft (px 4) solid "#f59e0b" + (".stat-card.badge-open" |> ".stat-count") ? color "#92400e" + ".stat-card.badge-inprogress" ? borderLeft (px 4) solid "#3b82f6" + (".stat-card.badge-inprogress" |> ".stat-count") ? color "#1e40af" + ".stat-card.badge-review" ? borderLeft (px 4) solid "#8b5cf6" + (".stat-card.badge-review" |> ".stat-count") ? color "#6b21a8" + ".stat-card.badge-approved" ? borderLeft (px 4) solid "#06b6d4" + (".stat-card.badge-approved" |> ".stat-count") ? color "#0e7490" + ".stat-card.badge-done" ? borderLeft (px 4) solid "#10b981" + (".stat-card.badge-done" |> ".stat-count") ? color "#065f46" + ".task-card" ? do + transition "box-shadow" (ms 150) ease (sec 0) + ".task-card" # hover ? do + boxShadow (NE.singleton (bsColor (rgba 0 0 0 0.15) (shadow (px 0) (px 4)))) + ".task-header" ? do + display flex + flexWrap Flexbox.wrap + alignItems center + Stylesheet.key "gap" ("8px" :: Text) + marginBottom (px 8) + ".task-id" ? do + fontFamily ["SF Mono", "Monaco", "monospace"] [monospace] + color "#0066cc" + textDecoration none + fontSize (px 14) + padding (px 4) (px 0) (px 4) (px 0) + ".task-id" # hover ? textDecoration underline + ".priority" ? do + fontSize (px 12) + color "#6b7280" + ".task-title" ? do + fontSize (px 16) + margin (px 0) (px 0) (px 0) (px 0) + ".empty-msg" ? do + color "#6b7280" + fontStyle italic + ".ready-link" ? do + fontSize (px 14) + color "#0066cc" + ".count-badge" ? do + backgroundColor "#0066cc" + color white + padding (px 4) (px 10) (px 4) (px 10) + borderRadius (px 12) (px 12) (px 12) (px 12) + fontSize (px 14) + verticalAlign middle + ".description" ? do + backgroundColor "#f9fafb" + padding (px 12) (px 12) (px 12) (px 12) + borderRadius (px 4) (px 4) (px 4) (px 4) + margin (px 0) (px 0) (px 0) (px 0) + ".diff-block" ? do + maxHeight (px 600) + overflowY auto + ".no-commit-msg" ? do + backgroundColor "#fff3cd" + border (px 1) solid "#ffc107" + borderRadius (px 8) (px 8) (px 8) (px 8) + padding (px 16) (px 16) (px 16) (px 16) + margin (px 16) (px 0) (px 16) (px 0) + ".conflict-warning" ? do + backgroundColor "#fee2e2" + border (px 1) solid "#ef4444" + borderRadius (px 8) (px 8) (px 8) (px 8) + padding (px 16) (px 16) (px 16) (px 16) + margin (px 16) (px 0) (px 16) (px 0) + +statusBadges :: Css +statusBadges = do + ".badge" ? do + display inlineBlock + padding (px 4) (px 10) (px 4) (px 10) + borderRadius (px 20) (px 20) (px 20) (px 20) + fontSize (px 12) + fontWeight (weight 500) + whiteSpace nowrap + ".badge-open" ? do + backgroundColor "#fef3c7" + color "#92400e" + ".badge-inprogress" ? do + backgroundColor "#dbeafe" + color "#1e40af" + ".badge-review" ? do + backgroundColor "#ede9fe" + color "#6b21a8" + ".badge-approved" ? do + backgroundColor "#cffafe" + color "#0e7490" + ".badge-done" ? do + backgroundColor "#d1fae5" + color "#065f46" + +buttonStyles :: Css +buttonStyles = do + ".btn" + <> ".action-btn" + <> ".filter-btn" + <> ".submit-btn" + <> ".accept-btn" + <> ".reject-btn" + <> ".review-link-btn" + ? do + display inlineBlock + minHeight (px 44) + padding (px 10) (px 20) (px 10) (px 20) + borderRadius (px 6) (px 6) (px 6) (px 6) + border (px 0) none transparent + fontSize (px 14) + fontWeight (weight 500) + textDecoration none + cursor pointer + textAlign center + transition "all" (ms 150) ease (sec 0) + Stylesheet.key "touch-action" ("manipulation" :: Text) + ".action-btn" ? do + backgroundColor white + border (px 1) solid "#d1d5db" + color "#374151" + ".action-btn" # hover ? do + backgroundColor "#f9fafb" + borderColor "#9ca3af" + ".action-btn-primary" <> ".filter-btn" <> ".submit-btn" ? do + backgroundColor "#0066cc" + color white + borderColor "#0066cc" + ".action-btn-primary" + # hover + <> ".filter-btn" + # hover + <> ".submit-btn" + # hover + ? do + backgroundColor "#0052a3" + ".accept-btn" ? do + backgroundColor "#10b981" + color white + ".accept-btn" # hover ? backgroundColor "#059669" + ".reject-btn" ? do + backgroundColor "#ef4444" + color white + ".reject-btn" # hover ? backgroundColor "#dc2626" + ".clear-btn" ? do + display inlineBlock + minHeight (px 44) + padding (px 10) (px 16) (px 10) (px 16) + backgroundColor "#6b7280" + color white + borderRadius (px 6) (px 6) (px 6) (px 6) + textDecoration none + fontSize (px 14) + cursor pointer + ".clear-btn" # hover ? backgroundColor "#4b5563" + ".review-link-btn" ? do + backgroundColor "#8b5cf6" + color white + ".review-link-btn" # hover ? backgroundColor "#7c3aed" + ".review-link-section" ? margin (px 16) (px 0) (px 16) (px 0) + +formStyles :: Css +formStyles = do + ".filter-row" ? do + display flex + flexWrap Flexbox.wrap + Stylesheet.key "gap" ("12px" :: Text) + alignItems flexEnd + ".filter-group" ? do + display flex + flexDirection column + Stylesheet.key "gap" ("4px" :: Text) + (".filter-group" |> label) ? do + fontSize (px 12) + color "#6b7280" + fontWeight (weight 500) + ".filter-select" <> ".filter-input" <> ".status-select" ? do + minHeight (px 44) + padding (px 10) (px 14) (px 10) (px 14) + border (px 1) solid "#d1d5db" + borderRadius (px 6) (px 6) (px 6) (px 6) + fontSize (px 14) + minWidth (px 120) + ".filter-input" ? minWidth (px 150) + ".inline-form" ? display inlineBlock + ".reject-form" ? do + display flex + Stylesheet.key "gap" ("8px" :: Text) + Stylesheet.key "flex" ("1" :: Text) + minWidth (px 250) + flexWrap Flexbox.wrap + ".reject-notes" ? do + Stylesheet.key "flex" ("1" :: Text) + minWidth (px 200) + minHeight (px 44) + padding (px 10) (px 14) (px 10) (px 14) + border (px 1) solid "#d1d5db" + borderRadius (px 6) (px 6) (px 6) (px 6) + fontSize (px 14) + Stylesheet.key "resize" ("vertical" :: Text) + +responsiveStyles :: Css +responsiveStyles = do + query Media.screen [Media.maxWidth (px 600)] <| do + body ? fontSize (px 15) + ".container" ? padding (px 12) (px 12) (px 12) (px 12) + ".nav-content" ? do + flexDirection column + alignItems flexStart + ".stats-grid" ? do + Stylesheet.key "grid-template-columns" ("repeat(2, 1fr)" :: Text) + ".detail-row" ? do + flexDirection column + Stylesheet.key "gap" ("4px" :: Text) + ".detail-label" ? width auto + ".filter-row" ? flexDirection column + ".filter-group" ? width (pct 100) + ".filter-select" <> ".filter-input" ? width (pct 100) + ".review-actions" ? do + flexDirection column + ".reject-form" ? do + width (pct 100) + flexDirection column + ".reject-notes" ? width (pct 100) + ".actions" ? flexDirection column + ".action-btn" ? width (pct 100) + +darkModeStyles :: Css +darkModeStyles = + query Media.screen [prefersDark] <| do + body ? do + backgroundColor "#111827" + color "#f3f4f6" + ".card" + <> ".task-card" + <> ".stat-card" + <> ".task-detail" + <> ".task-summary" + <> ".filter-form" + <> ".status-form" + <> ".diff-section" + <> ".review-actions" + ? do + backgroundColor "#1f2937" + boxShadow (NE.singleton (bsColor (rgba 0 0 0 0.3) (shadow (px 0) (px 2)))) + header ? do + backgroundColor "#1f2937" + boxShadow (NE.singleton (bsColor (rgba 0 0 0 0.3) (shadow (px 0) (px 2)))) + ".nav-brand" ? color "#f3f4f6" + "h2" <> "h3" ? color "#d1d5db" + a ? color "#60a5fa" + ".detail-row" ? borderBottomColor "#374151" + ".detail-label" + <> ".priority" + <> ".dep-type" + <> ".child-status" + <> ".empty-msg" + <> ".stat-label" + <> ".priority-desc" + ? color "#9ca3af" + ".child-title" ? color "#d1d5db" + code ? do + backgroundColor "#374151" + color "#f3f4f6" + ".detail-section" ? borderTopColor "#374151" + ".description" ? backgroundColor "#374151" + ".badge-open" ? do + backgroundColor "#78350f" + color "#fcd34d" + ".badge-inprogress" ? do + backgroundColor "#1e3a8a" + color "#93c5fd" + ".badge-review" ? do + backgroundColor "#4c1d95" + color "#c4b5fd" + ".badge-approved" ? do + backgroundColor "#164e63" + color "#67e8f9" + ".badge-done" ? do + backgroundColor "#064e3b" + color "#6ee7b7" + ".action-btn" ? do + backgroundColor "#374151" + borderColor "#4b5563" + color "#f3f4f6" + ".action-btn" # hover ? backgroundColor "#4b5563" + ".filter-select" <> ".filter-input" <> ".status-select" <> ".reject-notes" ? do + backgroundColor "#374151" + borderColor "#4b5563" + color "#f3f4f6" + +prefersDark :: Stylesheet.Feature +prefersDark = + Stylesheet.Feature "prefers-color-scheme" (Just (Clay.value ("dark" :: Text))) + +statusBadgeClass :: Text -> Text +statusBadgeClass status = case status of + "Open" -> "badge badge-open" + "InProgress" -> "badge badge-inprogress" + "Review" -> "badge badge-review" + "Approved" -> "badge badge-approved" + "Done" -> "badge badge-done" + _ -> "badge" + +priorityBadgeClass :: Text -> Text +priorityBadgeClass priority = case priority of + "P0" -> "badge badge-p0" + "P1" -> "badge badge-p1" + "P2" -> "badge badge-p2" + "P3" -> "badge badge-p3" + "P4" -> "badge badge-p4" + _ -> "badge" |
