use http::HeaderValue;
use http::header::CONTENT_SECURITY_POLICY;
use serde_json::Value;
use topcoat::Result;
use topcoat::context::Cx;
use topcoat::view::component;
use topcoat::view::view;
use crate::dashboard::Project;
use super::views::thread_title;
pub(crate) struct DashboardDocumentData<'a> {
pub(crate) base_path: &'a str,
pub(crate) projects: &'a [Project],
pub(crate) threads: &'a [Value],
pub(crate) account: Option<&'a Value>,
pub(crate) truncated: bool,
pub(crate) profile_label: &'a str,
pub(crate) proxy_active: bool,
}
pub(crate) async fn dashboard_document(cx: &Cx, data: DashboardDocumentData<'_>) -> Result {
let account_label = data.account.map(account_label).unwrap_or("Not signed in");
view! { cx =>
((CONTENT_SECURITY_POLICY, HeaderValue::from_static(
"default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; form-action 'self'"
)))
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="referrer" content="no-referrer">
<title>"Codex Web"</title>
<link rel="stylesheet" href="/assets/app.css">
<script defer="" src="/assets/home.js"></script>
</head>
<body data-dashboard="" data-base-path=(data.base_path)>
<header class="border-b border-border bg-background/95">
<div class="mx-auto flex h-16 max-w-6xl items-center gap-3 px-6 max-sm:px-4">
<span class="grid size-8 place-items-center rounded-lg bg-primary font-mono text-xs font-bold text-primary-foreground">">_"</span>
<strong class="text-sm">"Codex Web"</strong>
<div class="ml-auto flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
if data.proxy_active {
<span class="rounded-full border border-sky-500/30 bg-sky-500/10 px-2 py-0.5 font-mono text-[0.62rem] text-sky-600 dark:text-sky-400">"PROXY"</span>
}
<span class="max-w-32 truncate font-medium text-foreground">(data.profile_label)</span>
<span class="max-w-56 truncate">(account_label)</span>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-6xl px-6 py-12 max-sm:px-4 max-sm:py-8">
<section class="flex items-end gap-8 max-md:flex-col max-md:items-start">
<div class="min-w-0 flex-1">
<p class="font-mono text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-sky-600 dark:text-sky-400">"Workspace ledger"</p>
<h1 class="mt-3 max-w-3xl text-4xl font-semibold tracking-[-0.035em] max-sm:text-3xl">"Where should Codex work?"</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">"Resume a recent directory or open another folder on this server."</p>
</div>
<button type="button" data-open-picker="" class="shrink-0 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground shadow-xs hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">"Open a folder"</button>
</section>
if data.account.is_none() {
<section class="mt-10 grid gap-6 rounded-2xl border border-border bg-foreground/[0.02] p-6 md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.72fr)]">
<div>
<p class="font-mono text-[0.68rem] uppercase tracking-[0.16em] text-muted-foreground">"Account required"</p>
<h2 class="mt-2 text-xl font-semibold">"Connect Codex before starting work"</h2>
<p class="mt-2 text-sm leading-6 text-muted-foreground">"Device login works when this page and the server are on different machines."</p>
<button type="button" data-device-login="" class="mt-5 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground">"Continue with ChatGPT"</button>
<div data-device-result="" class="mt-4 hidden rounded-lg border border-border bg-background p-4 text-sm"></div>
</div>
<form data-api-key-form="" class="border-border md:border-l md:pl-6">
<label for="api-key" class="text-sm font-medium">"Or use an API key"</label>
<p class="mt-1 text-xs leading-5 text-muted-foreground">"The key is sent directly to Codex and is never stored by this page."</p>
<input id="api-key" name="apiKey" type="password" autocomplete="off" required="" class="mt-4 h-10 w-full rounded-lg border border-border bg-background px-3 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" placeholder="sk-…">
<button type="submit" class="mt-3 rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium shadow-xs hover:bg-foreground/5">"Use API key"</button>
<p data-login-error="" class="mt-3 hidden text-xs text-destructive"></p>
</form>
</section>
}
<section class="mt-12">
<div class="flex items-baseline gap-3">
<h2 class="text-sm font-semibold">"Recent projects"</h2>
<span class="font-mono text-[0.68rem] text-muted-foreground">(data.projects.len())" paths"</span>
</div>
if data.projects.is_empty() {
<button type="button" data-open-picker="" class="mt-4 flex w-full items-center justify-between rounded-xl border border-dashed border-border px-5 py-6 text-left hover:bg-foreground/[0.02]">
<span>
<strong class="block text-sm">"No recent projects"</strong>
<span class="mt-1 block text-xs text-muted-foreground">"Choose a directory to start the first session."</span>
</span>
<span class="font-mono text-lg text-muted-foreground">"→"</span>
</button>
} else {
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
for project in data.projects.iter().take(24) {
project_card(project: project, truncated: data.truncated, base_path: data.base_path)
}
</div>
}
</section>
<section class="mt-12">
<div class="flex items-baseline gap-3 border-b border-border pb-3">
<h2 class="text-sm font-semibold">"Recent sessions"</h2>
<span class="font-mono text-[0.68rem] text-muted-foreground">(data.threads.len())(if data.truncated { "+" } else { "" })" loaded"</span>
</div>
<div class="divide-y divide-border">
for thread in data.threads.iter().take(100) {
session_row(thread: thread, base_path: data.base_path)
}
</div>
</section>
</main>
directory_picker()
</body>
</html>
}
}
#[component]
async fn project_card(project: &Project, truncated: bool, base_path: &str) -> Result {
let encoded_cwd = urlencoding::encode(&project.cwd);
let preview = thread_title(&project.latest_thread);
let timestamp = recency(&project.latest_thread);
view! {
<article class="group relative overflow-hidden rounded-xl border border-border bg-background p-5 shadow-xs transition-colors hover:border-foreground/25">
<div class="flex items-start gap-3">
<span class="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-foreground/[0.025] font-mono text-xs text-sky-600 dark:text-sky-400">"⌁"</span>
<div class="min-w-0 flex-1">
<a href=(format!("{base_path}/new?cwd={encoded_cwd}")) class="font-semibold before:absolute before:inset-0">(project.name.as_str())</a>
<p class="mt-1 truncate font-mono text-[0.68rem] text-muted-foreground" title=(project.cwd.as_str())>(project.cwd.as_str())</p>
</div>
</div>
<p class="mt-5 truncate text-xs text-muted-foreground">(preview)</p>
<div class="mt-3 flex items-center gap-2 font-mono text-[0.65rem] text-muted-foreground">
<span data-relative-time=(timestamp)></span>
<span>"·"</span>
<span>(project.session_count)(if truncated { "+" } else { "" })" sessions"</span>
status_badge(thread: &project.latest_thread)
</div>
</article>
}
}
#[component]
async fn session_row(thread: &Value, base_path: &str) -> Result {
let id = string(thread, "id");
let cwd = string(thread, "cwd");
let timestamp = recency(thread);
view! {
<a href=(format!("{base_path}/thread/{id}")) class="group grid grid-cols-[minmax(0,1fr)_auto] gap-x-5 gap-y-1 py-4 hover:bg-foreground/[0.018] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="truncate text-sm font-medium">(thread_title(thread))</span>
<span class="font-mono text-[0.66rem] text-muted-foreground" data-relative-time=(timestamp)></span>
<span class="truncate font-mono text-[0.68rem] text-muted-foreground">(cwd)</span>
status_badge(thread: thread)
</a>
}
}
#[component]
async fn status_badge(thread: &Value) -> Result {
let status = thread
.get("status")
.and_then(|status| status.get("type").or(Some(status)))
.and_then(Value::as_str)
.unwrap_or("notLoaded");
let label = match status {
"active" => "Active",
"idle" => "Idle",
"systemError" => "Error",
_ => "Stored",
};
view! {
<span class=(if status == "active" {
"ml-auto inline-flex items-center gap-1.5 text-[0.65rem] text-sky-600 dark:text-sky-400"
} else if status == "systemError" {
"ml-auto inline-flex items-center gap-1.5 text-[0.65rem] text-destructive"
} else {
"ml-auto inline-flex items-center gap-1.5 text-[0.65rem] text-muted-foreground"
})>
<span class=(if status == "active" { "size-1.5 rounded-full bg-sky-500" } else { "size-1.5 rounded-full bg-foreground/20" })></span>
(label)
</span>
}
}
#[component]
async fn directory_picker() -> Result {
view! {
<div data-picker="" class="fixed inset-0 z-50 hidden place-items-center bg-background/70 p-4 backdrop-blur-sm">
<section role="dialog" aria-modal="true" aria-labelledby="picker-title" class="flex max-h-[min(44rem,90vh)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-background shadow-sm">
<header class="flex items-center gap-3 border-b border-border px-5 py-4">
<div class="min-w-0 flex-1">
<h2 id="picker-title" class="text-sm font-semibold">"Choose a directory"</h2>
<p data-picker-path="" class="mt-1 truncate font-mono text-[0.68rem] text-muted-foreground"></p>
</div>
<button type="button" data-close-picker="" class="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-foreground/5 hover:text-foreground" aria-label="Close">"×"</button>
</header>
<nav data-breadcrumbs="" class="flex min-h-11 items-center gap-1 overflow-x-auto border-b border-border px-4 font-mono text-xs"></nav>
<div data-directory-list="" class="min-h-56 flex-1 overflow-y-auto p-2"></div>
<p data-picker-error="" class="hidden border-t border-border px-5 py-3 text-xs text-destructive"></p>
<footer class="flex items-center gap-3 border-t border-border px-5 py-4">
<button type="button" data-parent-directory="" class="rounded-lg border border-border px-3 py-2 text-sm hover:bg-foreground/5">"Up"</button>
<button type="button" data-open-directory="" class="ml-auto rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground">"Open this folder"</button>
</footer>
</section>
</div>
}
}
fn account_label(account: &Value) -> &str {
account
.get("email")
.and_then(Value::as_str)
.unwrap_or_else(|| match string(account, "type") {
"apiKey" => "API key",
"chatgpt" => "ChatGPT",
_ => "Connected",
})
}
fn recency(thread: &Value) -> i64 {
thread
.get("recencyAt")
.or_else(|| thread.get("updatedAt"))
.and_then(Value::as_i64)
.unwrap_or_default()
}
fn string<'a>(value: &'a Value, field: &str) -> &'a str {
value.get(field).and_then(Value::as_str).unwrap_or_default()
}
#[cfg(test)]
#[path = "dashboard_tests.rs"]
mod tests;