umbral-admin 0.0.7

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" %}
{% 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 %}