{#
dashboard.html
==============
Main dashboard page — header greeting + quick stats strip +
model cards grid + 12-column widget grid. Each widget
placeholder hx-gets its data endpoint on load.
Restored from stash 77af7f8 ("On main: only-projection-wip"),
which was the in-progress dashboard pass that never got
committed — see commit msg.
#}
{% extends "admin/base.html" %}
{% block title %}Dashboard — {{ site_title }}{% endblock %}
{% block content %}
{# ================================================================
Dashboard header — time-of-day greeting + wall clock. The
handler computes `now_hour` / `now_minute` against UTC; for a
tz-aware greeting wire in `umbral::timezone::active_tz()` and
adjust before passing.
================================================================ #}
<div class="mb-xl">
<div class="flex flex-col sm:flex-row sm:items-end justify-between gap-md">
<div>
<h1 class="font-h1 text-h1 text-on-surface leading-tight">
{%- set hour = now_hour | default(value=12) -%}
{% if hour < 12 %}Good morning{% elif hour < 17 %}Good afternoon{% else %}Good evening{% endif %}, {{ user }}.
</h1>
<p class="text-body-sm text-outline mt-xs">
{{ site_title }} admin. {{ model_count }} models across {{ plugin_count }} plugins.
</p>
</div>
<div class="hidden sm:flex items-end text-right flex-shrink-0">
<p class="text-body-sm text-outline tabular-nums">
{{ "%02d" | format(hour) }}:{{ "%02d" | format(now_minute | default(value=0)) }}
</p>
</div>
</div>
</div>
{# ================================================================
Quick numbers — a compact stats strip before the model grid.
Horizontal scroll on overflow so the layout doesn't break on
narrow screens; uses the same surface tokens as the model
cards below for visual continuity.
================================================================ #}
{% if model_cards %}
<div class="flex gap-sm mb-lg overflow-x-auto pb-sm scrollbar-none snap-x">
<div class="flex-shrink-0 bg-surface-container border border-outline-variant rounded-xl px-md py-md min-w-[136px] snap-start">
<p class="text-label-sm text-outline uppercase tracking-wider mb-xs">Total Entries</p>
<p class="font-display text-2xl text-on-surface tabular-nums leading-none">{{ total_rows }}</p>
</div>
<div class="flex-shrink-0 bg-surface-container border border-outline-variant rounded-xl px-md py-md min-w-[136px] snap-start">
<p class="text-label-sm text-outline uppercase tracking-wider mb-xs">Models</p>
<p class="font-display text-2xl text-on-surface tabular-nums leading-none">{{ model_count }}</p>
</div>
<div class="flex-shrink-0 bg-surface-container border border-outline-variant rounded-xl px-md py-md min-w-[136px] snap-start">
<p class="text-label-sm text-outline uppercase tracking-wider mb-xs">Plugins</p>
<p class="font-display text-2xl text-on-surface tabular-nums leading-none">{{ plugin_count }}</p>
</div>
{% for card in model_cards[:4] %}
<a href="{{ card.url }}" class="flex-shrink-0 bg-surface-container border border-outline-variant rounded-xl px-md py-md min-w-[136px] snap-start hover:bg-surface-container-high transition-all cursor-pointer no-underline group">
<p class="text-label-sm text-outline uppercase tracking-wider mb-xs group-hover:text-primary transition-colors">{{ card.label }}</p>
<div class="flex items-baseline gap-xs">
<p class="font-display text-2xl text-on-surface tabular-nums leading-none">{{ card.count }}</p>
<span class="text-label-sm text-outline">entries</span>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{# ================================================================
Widget sections — each named group renders as its own
<section> with title + (optional) subtitle + widget grid.
Important data first, registry second, so the operator's
eye lands on KPIs before navigating to the data tables.
Per-kind loading skeletons live inside each widget cell so
the first-paint matches the eventual widget shape (KPI / card
/ line / bar / feed / table), preventing the page from
jumping when HTMX swaps in the real content.
================================================================ #}
{% for section in widget_sections %}
{% if section.widgets and section.widgets | length > 0 %}
<section class="mb-xl">
<div class="flex items-end justify-between mb-md gap-md">
<div class="min-w-0">
<h2 class="font-h3 text-h3 text-on-surface">{{ section.title }}</h2>
{% if section.subtitle %}
<p class="text-label-sm text-outline mt-xs truncate">{{ section.subtitle }}</p>
{% endif %}
</div>
<span class="text-label-sm text-outline flex-shrink-0">{{ section.widgets | length }} active</span>
</div>
<div class="grid grid-cols-12 gap-sm auto-rows-[120px]">
{% for w in section.widgets %}
{%- set col_span = w.span.cols if w.span and w.span.cols else 3 -%}
{%- set row_span = w.span.rows if w.span and w.span.rows else 1 -%}
{# Grid span is a DATA-DRIVEN per-widget value, so it lives in an
inline style, not a `col-span-N` utility class — Tailwind purges
interpolated class names (`col-span-{{ ... }}`), so those classes
never made it into admin.css and every widget collapsed to one
column. Inline grid styles are purge-proof and need no rebuild. #}
<div
class="bg-surface-container border border-outline-variant rounded-xl overflow-hidden"
style="grid-column: span {{ col_span }} / span {{ col_span }}; grid-row: span {{ row_span }} / span {{ row_span }};"
data-widget-kind="{{ w.kind }}"
id="widget-{{ w.key }}"
hx-get="{{ admin_base }}/api/dashboard/widgets/{{ w.key }}/data"
hx-trigger="load"
hx-swap="innerHTML"
hx-target="#widget-{{ w.key }}"
>
{# Per-kind loading skeleton — matches the real widget's shape #}
{% if w.kind == "kpi" or w.kind == "card" %}
<div class="h-full p-lg flex flex-col justify-between animate-pulse">
<div class="flex items-start justify-between gap-sm">
<div class="h-3 bg-surface-container-high rounded w-1/3"></div>
<div class="w-8 h-8 rounded-lg bg-surface-container-low border border-divider-soft"></div>
</div>
<div class="mt-auto">
<div class="h-9 bg-surface-container-high rounded w-1/2"></div>
<div class="h-3 bg-surface-container-high rounded w-1/4 mt-sm"></div>
</div>
</div>
{% elif w.kind in ["line", "bar"] %}
<div class="h-full p-md flex flex-col animate-pulse">
<div class="h-3 bg-surface-container-high rounded w-1/3 flex-shrink-0 mb-sm"></div>
<div class="flex-1 bg-surface-container-high rounded"></div>
</div>
{% elif w.kind == "donut" %}
<div class="h-full p-md flex flex-col animate-pulse">
<div class="h-3 bg-surface-container-high rounded w-1/3 flex-shrink-0 mb-md"></div>
<div class="flex-1 flex items-center justify-center">
<div class="w-24 h-24 rounded-full border-[14px] border-surface-container-high"></div>
</div>
</div>
{% elif w.kind == "radial" %}
<div class="h-full p-md flex flex-col animate-pulse">
<div class="h-3 bg-surface-container-high rounded w-1/3 flex-shrink-0 mb-md"></div>
<div class="flex-1 flex items-center justify-center">
<div class="w-24 h-24 rounded-full border-[10px] border-surface-container-high"></div>
</div>
</div>
{% elif w.kind == "heatmap" %}
<div class="h-full p-md flex flex-col animate-pulse">
<div class="h-3 bg-surface-container-high rounded w-1/3 flex-shrink-0 mb-md"></div>
<div class="flex-1 grid grid-cols-6 grid-rows-4 gap-1">
{% for i in range(24) %}<div class="bg-surface-container-high rounded-sm"></div>{% endfor %}
</div>
</div>
{% elif w.kind == "progress" %}
<div class="h-full p-md flex flex-col gap-sm animate-pulse">
<div class="h-3 bg-surface-container-high rounded w-1/3 flex-shrink-0 mb-xs"></div>
<div class="space-y-xs">
<div class="h-2 bg-surface-container-high rounded w-1/4"></div>
<div class="h-2 bg-surface-container-high rounded-full w-full"></div>
</div>
<div class="space-y-xs">
<div class="h-2 bg-surface-container-high rounded w-1/3"></div>
<div class="h-2 bg-surface-container-high rounded-full w-3/4"></div>
</div>
<div class="space-y-xs">
<div class="h-2 bg-surface-container-high rounded w-1/4"></div>
<div class="h-2 bg-surface-container-high rounded-full w-1/2"></div>
</div>
<div class="space-y-xs">
<div class="h-2 bg-surface-container-high rounded w-1/3"></div>
<div class="h-2 bg-surface-container-high rounded-full w-1/3"></div>
</div>
</div>
{% elif w.kind == "feed" %}
<div class="h-full p-md flex flex-col animate-pulse">
<div class="h-3 bg-surface-container-high rounded w-1/3 flex-shrink-0 mb-sm"></div>
<div class="flex-1 space-y-sm">
<div class="flex items-start gap-sm">
<div class="w-7 h-7 rounded-full bg-surface-container-high flex-shrink-0"></div>
<div class="flex-1 space-y-xs">
<div class="h-3 bg-surface-container-high rounded w-3/4"></div>
<div class="h-2 bg-surface-container-high rounded w-1/4"></div>
</div>
</div>
<div class="flex items-start gap-sm">
<div class="w-7 h-7 rounded-full bg-surface-container-high flex-shrink-0"></div>
<div class="flex-1 space-y-xs">
<div class="h-3 bg-surface-container-high rounded w-2/3"></div>
<div class="h-2 bg-surface-container-high rounded w-1/3"></div>
</div>
</div>
<div class="flex items-start gap-sm">
<div class="w-7 h-7 rounded-full bg-surface-container-high flex-shrink-0"></div>
<div class="flex-1 space-y-xs">
<div class="h-3 bg-surface-container-high rounded w-3/4"></div>
<div class="h-2 bg-surface-container-high rounded w-1/4"></div>
</div>
</div>
</div>
</div>
{% elif w.kind == "table" %}
<div class="h-full flex flex-col animate-pulse">
<div class="px-md pt-md pb-sm">
<div class="h-3 bg-surface-container-high rounded w-1/4"></div>
</div>
<div class="flex-1 px-md pb-md space-y-xs">
<div class="h-6 bg-surface-container-high rounded"></div>
<div class="h-6 bg-surface-container-high rounded opacity-60"></div>
<div class="h-6 bg-surface-container-high rounded opacity-40"></div>
</div>
</div>
{% else %}
<div class="h-full p-md flex flex-col gap-sm animate-pulse">
<div class="h-3 bg-surface-container-high rounded w-1/3"></div>
<div class="flex-1 bg-surface-container-high rounded"></div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{# ================================================================
Model cards grid — one card per registered model. Icon top-
right, count + "entries" label bottom-left, and a thin
progress bar at the bottom showing this model's share of
total rows across the app.
Filtering happens in the handler via `DashboardModelsConfig`
(default `All`, configurable with `.dashboard_models_only(...)`
or `.dashboard_models_hidden()`) — `model_cards` here is just
the final slice the operator wants to see. An empty Vec hides
the whole section.
================================================================ #}
{% if model_cards %}
<section class="mb-xl">
<div class="flex items-end justify-between mb-md gap-md">
<div class="min-w-0">
<h2 class="font-h3 text-h3 text-on-surface">{{ dashboard_models_title or "Models" }}</h2>
{% if dashboard_models_subtitle %}
<p class="text-label-sm text-outline mt-xs truncate">{{ dashboard_models_subtitle }}</p>
{% endif %}
</div>
<span class="text-label-sm text-outline tabular-nums flex-shrink-0">{{ model_cards | length }} total</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6 gap-sm">
{% for card in model_cards %}
<a
href="{{ card.url }}"
class="group relative flex min-h-[104px] flex-col justify-between gap-md p-md bg-surface-container border border-outline-variant rounded-xl hover:bg-surface-container-high transition-all duration-200 cursor-pointer no-underline overflow-hidden"
>
<div class="relative flex items-start justify-between gap-sm">
<p class="text-label-sm text-outline uppercase tracking-wider truncate leading-snug group-hover:text-on-surface-variant transition-colors">{{ card.label }}</p>
<span class="w-9 h-9 rounded-lg bg-surface-container-high flex items-center justify-center flex-shrink-0 text-on-surface-variant group-hover:bg-surface-container-highest group-hover:text-on-surface transition-colors" aria-hidden="true">
<i data-lucide="{{ card.icon }}" class="w-[18px] h-[18px]"></i>
</span>
</div>
<div class="relative flex items-baseline gap-xs">
<p class="font-display text-2xl text-on-surface tabular-nums leading-none">{{ card.count }}</p>
<span class="text-label-sm text-outline">{{ "entry" if card.count == 1 else "entries" }}</span>
</div>
<div class="relative mt-auto">
<div class="h-1 bg-surface-container-low rounded-full overflow-hidden">
{% set fill_pct = (card.count / (total_rows + 1)) * 100 %}
<div class="h-full bg-primary/20 group-hover:bg-primary/40 transition-colors duration-300 rounded-full" style="width: {{ [fill_pct, 100] | min | round(1) }}%"></div>
</div>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{# Empty-state fallback — shown only when BOTH every widget
section is empty AND model_cards is empty. With the new
ordering Widgets come first; this block stays as the catch-
all for fresh installs that haven't registered anything yet. #}
{%- set total_widgets = widget_sections | map(attribute="widgets") | map("length") | sum -%}
{% if total_widgets == 0 and not model_cards %}
<div class="flex flex-col items-center justify-center py-xl text-center">
<div class="w-20 h-20 rounded-2xl bg-surface-container-low border border-outline-variant flex items-center justify-center mb-lg">
<i data-lucide="layout-dashboard" class="w-10 h-10 text-outline/40"></i>
</div>
<h3 class="font-h3 text-h3 text-on-surface mb-sm">Empty dashboard</h3>
<p class="text-body-sm text-outline max-w-md mb-lg">
Register models and dashboard widgets to populate this page.
</p>
<div class="flex gap-sm">
<a href="https://github.com/umbral-rs/umbral" class="px-lg py-sm rounded-xl bg-primary text-on-primary hover:opacity-90 font-label-md text-label-md transition-all no-underline">Documentation</a>
<span class="px-lg py-sm rounded-xl border border-outline-variant bg-surface-container text-on-surface-variant font-label-md text-label-md cursor-default">No data yet</span>
</div>
</div>
{% endif %}
{# Command palette slot for Ctrl-K #}
<div id="umbral-palette-slot"></div>
{% endblock %}