{#
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" %}
{% from "admin/_macros/widget_grid.html" import widget_grid %}
{% 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 border border-outline-variant rounded-xl shadow-card 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 border border-outline-variant rounded-xl shadow-card 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 border border-outline-variant rounded-xl shadow-card 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 border border-outline-variant rounded-xl shadow-card 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.
================================================================ #}
{{ widget_grid(widget_sections, admin_base) }}
{# ================================================================
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 border border-outline-variant rounded-xl shadow-card 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 %}