umbral-admin 0.0.3

Auto-generated CRUD admin UI for umbral models.
Documentation
{#
  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 %}