ontoref-derive: #[onto_mcp_tool] attribute macro registers MCP tool unit-structs in
the catalog at link time via inventory::submit!; annotated item is emitted unchanged,
ToolBase/AsyncTool impls stay on the struct. All 34 tools migrated from manual wiring
(net +5: ontoref_list_projects, ontoref_search, ontoref_describe,
ontoref_list_ontology_extensions, ontoref_get_ontology_extension).
validate modes (ADR-018): reads level_hierarchy from workflow.ncl and checks every
.ncl mode for level declared, strategy declared, delegate chain coherent, compose
extends valid. mode resolve <id> shows which hierarchy level handles a mode and why.
--self-test generates synthetic fixtures in a temp dir for CI smoke-testing.
validate run-cargo: two-step Cargo.toml resolution — workspace layout first
(crates/<check.crate>/Cargo.toml), single-crate fallback by package name or repo
basename. Lets the same ADR constraint shape apply to workspace and single-crate repos.
ontology/schemas/manifest.ncl: registry_topology_type contract — multi-registry
coordination, push targets, participant scopes, per-namespace capability.
reflection/requirements/base.ncl: oras ≥1.2.0, cosign ≥2.0.0, sops ≥3.9.0, age
≥1.1.0, restic declared as Hard/Soft requirements with version_min, check_cmd, and
install_hint (ADR-017 toolchain surface).
ADR-019: per-file recipient routing for tenant isolation without multi-vault. Schema
additions: sops.recipient_groups + sops.recipient_rules in ontoref-project.ncl.
secrets-bootstrap generates .sops.yaml from project.ncl in declarative mode. Three
new secrets-audit checks: recipient-routing-coherent, recipient-routing-coverage,
no-multi-vault. Adoption templates: single-team/, multi-tenant/, agent-first/.
Integration templates: domain-producer/, mode-producer/, mode-consumer/.
UI: project_picker surfaces registry badge (⟳ participant) and vault badge
(⛁ vault_id · N, green=declarative / amber=legacy) per project card. Expanded panel
adds collapsible Registry section with namespace, endpoint, and push/pull capability.
manage.html gains Runtime Services card — MCP and GraphQL toggleable without restart
via HTMX POST /ui/manage/services/{service}/toggle.
describe.nu: capabilities JSON includes registry_topology and vault_state per project.
sync.nu: drift check extended to detect //! absence on newly registered crates.
qa.ncl: six entries — credential-vault-best-practice (layered data-flow diagram),
credential-vault-templates (paths A/B/C), credential-vault-troubleshooting (15 named
errors), integration-what-and-why (ADR-042 OCI federation), integration-how-to-implement,
integration-troubleshooting.
on+re: core.ncl + manifest.ncl updated to reflect OCI, MCP, and mode-hierarchy nodes.
Deleted stale presentation assets (2026-02 slides + voice notes).
481 lines
22 KiB
HTML
481 lines
22 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Projects — Ontoref{% endblock title %}
|
|
|
|
{% block head %}
|
|
<script>
|
|
(function(){
|
|
var last = "";
|
|
try { last = localStorage.getItem("ontoref-last-project") || ""; } catch(_) {}
|
|
if (!last) return;
|
|
document.addEventListener("DOMContentLoaded", function() {
|
|
var banner = document.getElementById("resume-banner");
|
|
var lbl = document.getElementById("resume-label");
|
|
if (!banner || !lbl) return;
|
|
lbl.textContent = last;
|
|
banner.href = "/ui/" + encodeURIComponent(last) + "/";
|
|
banner.classList.remove("hidden");
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock head %}
|
|
|
|
{% block content %}
|
|
<!-- Resume last project -->
|
|
<a id="resume-banner" href="#" class="hidden mb-4 flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary/10 border border-primary/20 hover:bg-primary/20 transition-colors text-sm font-medium">
|
|
<svg class="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
Resume <span id="resume-label" class="font-mono text-primary"></span>
|
|
<span class="ml-auto text-xs text-base-content/40">last project</span>
|
|
</a>
|
|
|
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h1 class="text-2xl font-bold">Projects</h1>
|
|
<p class="text-base-content/50 text-sm mt-1" id="project-count-label">
|
|
{{ projects | length }} project{% if projects | length != 1 %}s{% endif %} registered
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative">
|
|
<input id="project-filter" type="text" placeholder="Filter projects…"
|
|
autocomplete="off" spellcheck="false"
|
|
class="input input-sm input-bordered font-mono w-48 pl-8 focus:w-64 transition-all"
|
|
oninput="filterProjects(this.value)">
|
|
<svg class="w-4 h-4 absolute left-2 top-1/2 -translate-y-1/2 text-base-content/30 pointer-events-none"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
|
</svg>
|
|
</div>
|
|
<a href="/ui/manage" class="btn btn-sm btn-ghost gap-1.5">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
</svg>
|
|
Manage
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{% if projects %}
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4" id="project-grid">
|
|
{% for p in projects %}
|
|
<div class="card bg-base-200 border border-base-content/10" data-slug="{{ p.slug }}" data-filter-text="{{ p.slug }} {{ p.description | default(value='') }} {{ p.card.tagline | default(value='') }}">
|
|
<!-- Card header: always visible -->
|
|
<div class="card-body gap-0 py-4 px-5">
|
|
|
|
<!-- Title row -->
|
|
<div class="flex items-start justify-between gap-2 mb-1">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<a href="/ui/{{ p.slug }}/"
|
|
class="card-title text-base font-mono link link-hover text-primary">{{ p.slug }}</a>
|
|
{% if p.auth %}
|
|
<span class="badge badge-warning badge-xs flex-shrink-0">protected</span>
|
|
{% else %}
|
|
<span class="badge badge-neutral badge-xs flex-shrink-0">open</span>
|
|
{% endif %}
|
|
{% if p.opmode == "daemon" %}
|
|
<span class="badge badge-success badge-xs flex-shrink-0 gap-1">
|
|
<span class="w-1 h-1 rounded-full bg-current inline-block"></span>daemon
|
|
</span>
|
|
{% elif p.opmode == "push" %}
|
|
<span class="badge badge-info badge-xs flex-shrink-0">push</span>
|
|
{% endif %}
|
|
{% if p.default_mode %}
|
|
<span class="badge badge-ghost badge-xs flex-shrink-0 font-mono">{{ p.default_mode }}</span>
|
|
{% endif %}
|
|
{% if p.repo_kind %}
|
|
<span class="badge badge-outline badge-xs flex-shrink-0 text-base-content/40">{{ p.repo_kind }}</span>
|
|
{% endif %}
|
|
{% if p.registry_participant %}
|
|
<span class="badge badge-outline badge-xs flex-shrink-0 font-mono text-accent/70" title="Registry participant: {{ p.registry_participant }}">
|
|
⟳ {{ p.registry_participant }}
|
|
</span>
|
|
{% endif %}
|
|
{% if p.has_vault %}
|
|
<span class="badge badge-outline badge-xs flex-shrink-0 font-mono {% if p.vault_mode == 'declarative' %}text-success{% else %}text-warning{% endif %}" title="Vault {{ p.vault_id }} · {{ p.vault_mode }} mode · {{ p.vault_recipients }} recipients">
|
|
⛁ {{ p.vault_id }}{% if p.vault_recipients > 0 %} · {{ p.vault_recipients }}{% endif %}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
<!-- Quick-access shortcut icons -->
|
|
<div class="flex items-center gap-0.5 flex-shrink-0">
|
|
{% if p.card %}
|
|
<button onclick="openCard('{{ p.slug }}')" title="Project card"
|
|
class="btn btn-ghost btn-xs">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
</button>
|
|
{% endif %}
|
|
<a href="/ui/{{ p.slug }}/search" title="Search"
|
|
class="btn btn-ghost btn-xs">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
|
</svg>
|
|
</a>
|
|
<a href="/ui/{{ p.slug }}/backlog" title="Backlog"
|
|
class="btn btn-ghost btn-xs">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
|
</svg>
|
|
</a>
|
|
<a href="/ui/{{ p.slug }}/" title="Open dashboard"
|
|
class="btn btn-ghost btn-xs">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% if p.card and p.card.tagline %}
|
|
<p class="text-sm text-base-content/60 italic leading-snug mb-1.5">{{ p.card.tagline }}</p>
|
|
{% elif p.description %}
|
|
<p class="text-sm text-base-content/70 leading-snug mb-1.5">{{ p.description }}</p>
|
|
{% endif %}
|
|
<p class="text-xs font-mono text-base-content/35 truncate mb-2" title="{{ p.root }}">{{ p.root }}</p>
|
|
|
|
<!-- Git remotes -->
|
|
{% if p.repos %}
|
|
<div class="flex flex-wrap gap-1 mb-2">
|
|
{% for r in p.repos %}
|
|
<a href="{{ r.url }}" target="_blank" rel="noopener"
|
|
class="badge badge-xs badge-ghost font-mono gap-1 border border-base-content/10 hover:border-primary/40 hover:text-primary transition-colors"
|
|
title="{{ r.url }}">
|
|
<svg class="w-2.5 h-2.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
|
</svg>
|
|
{{ r.name }}
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Showcase + Generated links -->
|
|
{% if p.showcase or p.generated %}
|
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
|
{% for s in p.showcase %}
|
|
<a href="{{ s.url }}" target="_blank" rel="noopener"
|
|
class="btn btn-xs btn-ghost gap-1 border border-base-content/10">
|
|
{% if s.id == "branding" %}🎨{% elif s.id == "web" %}🌐{% elif s.id == "presentation" %}📊{% endif %}
|
|
{{ s.label }}
|
|
</a>
|
|
{% endfor %}
|
|
{% for g in p.generated %}
|
|
<a href="{{ g.url }}" target="_blank" rel="noopener"
|
|
class="btn btn-xs btn-ghost gap-1 border border-base-content/10 opacity-70">
|
|
📄 {{ g.label }}
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Summary badges -->
|
|
<div class="flex flex-wrap gap-1.5 mb-3">
|
|
{% if p.session_count > 0 %}
|
|
<span class="badge badge-sm badge-success gap-1">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-current inline-block"></span>
|
|
{{ p.session_count }} session{% if p.session_count != 1 %}s{% endif %}
|
|
</span>
|
|
{% else %}
|
|
<span class="badge badge-sm badge-ghost">no sessions</span>
|
|
{% endif %}
|
|
|
|
{% if p.notif_count > 0 %}
|
|
<span class="badge badge-sm badge-warning">{{ p.notif_count }} notif</span>
|
|
{% endif %}
|
|
|
|
{% if p.backlog_open > 0 %}
|
|
<span class="badge badge-sm badge-info">{{ p.backlog_open }} open</span>
|
|
{% endif %}
|
|
|
|
{% if p.layers %}
|
|
<span class="badge badge-sm badge-ghost">{{ p.layers | length }} layers</span>
|
|
{% endif %}
|
|
|
|
{% if p.op_modes %}
|
|
<span class="badge badge-sm badge-ghost">{{ p.op_modes | length }} modes</span>
|
|
{% endif %}
|
|
|
|
<span id="mig-badge-{{ p.slug }}" class="badge badge-sm badge-warning hidden"></span>
|
|
</div>
|
|
|
|
<!-- Accordion panels -->
|
|
<div class="space-y-1">
|
|
|
|
<div id="mig-panel-{{ p.slug }}"
|
|
hx-get="/ui/{{ p.slug }}/migrations/pending"
|
|
hx-trigger="revealed"
|
|
hx-swap="innerHTML"></div>
|
|
|
|
{% if p.registry_participant %}
|
|
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
|
Registry · <span class="font-mono font-normal text-accent/80">{{ p.registry_participant }}</span>
|
|
</summary>
|
|
<div class="collapse-content px-3 pb-3">
|
|
{% if p.registry_endpoint %}
|
|
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1">Endpoint</p>
|
|
<p class="font-mono text-xs text-base-content/60 mb-2 break-all">{{ p.registry_endpoint }}</p>
|
|
{% endif %}
|
|
{% if p.registry_namespaces %}
|
|
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1">Namespaces</p>
|
|
<div class="space-y-0.5">
|
|
{% for ns in p.registry_namespaces %}
|
|
<p class="font-mono text-xs text-base-content/70">{{ ns }}</p>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
{% if p.has_vault %}
|
|
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
|
Vault · <span class="font-mono font-normal {% if p.vault_mode == 'declarative' %}text-success{% else %}text-warning{% endif %}">{{ p.vault_id }}</span>
|
|
<span class="ml-2 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide {% if p.vault_mode == 'declarative' %}bg-success/15 text-success{% else %}bg-warning/15 text-warning{% endif %}">{{ p.vault_mode }}</span>
|
|
</summary>
|
|
<div class="collapse-content px-3 pb-3">
|
|
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1">Mode</p>
|
|
<p class="font-mono text-xs text-base-content/70 mb-2">
|
|
{% if p.vault_mode == 'declarative' %}per-file recipient routing (ADR-019){% else %}legacy uniform recipients (ADR-017){% endif %}
|
|
</p>
|
|
{% if p.vault_recipients > 0 %}
|
|
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1">Recipients in access.sops.yaml</p>
|
|
<p class="font-mono text-xs text-base-content/70 mb-2">{{ p.vault_recipients }}</p>
|
|
{% endif %}
|
|
{% if p.vault_last_op %}
|
|
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1">Last audit entry</p>
|
|
<p class="font-mono text-xs text-base-content/70 break-all">{{ p.vault_last_op }}</p>
|
|
{% endif %}
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
{% if p.layers or p.op_modes %}
|
|
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
|
Features & Layers
|
|
</summary>
|
|
<div class="collapse-content px-3 pb-3">
|
|
{% if p.layers %}
|
|
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1.5">Layers</p>
|
|
<div class="space-y-1 mb-3">
|
|
{% for l in p.layers %}
|
|
<div class="flex gap-2">
|
|
<span class="badge badge-xs badge-ghost font-mono flex-shrink-0 mt-0.5">{{ l.id }}</span>
|
|
<span class="text-xs text-base-content/70">{{ l.description }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
{% if p.op_modes %}
|
|
<p class="text-xs font-medium text-base-content/40 uppercase tracking-wider mb-1.5">Operational Modes</p>
|
|
<div class="space-y-1">
|
|
{% for m in p.op_modes %}
|
|
<div class="flex gap-2">
|
|
<span class="badge badge-xs badge-primary font-mono flex-shrink-0 mt-0.5">{{ m.id }}</span>
|
|
<span class="text-xs text-base-content/70">{{ m.description }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
{% if p.backlog_items %}
|
|
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
|
Backlog
|
|
<span class="badge badge-xs badge-info ml-1">{{ p.backlog_open }} open</span>
|
|
</summary>
|
|
<div class="collapse-content px-3 pb-3">
|
|
<div class="space-y-1.5">
|
|
{% for it in p.backlog_items %}
|
|
<div class="flex items-start gap-1.5">
|
|
<span class="badge badge-xs font-mono flex-shrink-0 mt-0.5
|
|
{% if it.status == 'Open' %}badge-info
|
|
{% elif it.status == 'Done' %}badge-success
|
|
{% else %}badge-ghost{% endif %}">
|
|
{{ it.status }}
|
|
</span>
|
|
<span class="badge badge-xs flex-shrink-0 mt-0.5
|
|
{% if it.priority == 'Critical' %}badge-error
|
|
{% elif it.priority == 'High' %}badge-warning
|
|
{% else %}badge-ghost{% endif %}">
|
|
{{ it.priority }}
|
|
</span>
|
|
<span class="text-xs text-base-content/80 leading-tight">{{ it.title }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<a href="/ui/{{ p.slug }}/backlog" class="btn btn-xs btn-ghost mt-2 w-full">
|
|
Manage backlog →
|
|
</a>
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
{% if p.sessions %}
|
|
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
|
Sessions
|
|
<span class="badge badge-xs badge-success ml-1">{{ p.session_count }}</span>
|
|
</summary>
|
|
<div class="collapse-content px-3 pb-3">
|
|
<div class="space-y-1">
|
|
{% for s in p.sessions %}
|
|
<div class="flex items-center gap-1.5 text-xs">
|
|
<span class="badge badge-xs badge-ghost font-mono">{{ s.actor_type }}</span>
|
|
<span class="text-base-content/60">{{ s.hostname }}</span>
|
|
<span class="text-base-content/30 ml-auto">{{ s.last_seen_ago }}s ago</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
{% if p.notifications %}
|
|
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
|
|
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
|
|
Notifications
|
|
<span class="badge badge-xs badge-warning ml-1">{{ p.notif_count }}</span>
|
|
</summary>
|
|
<div class="collapse-content px-3 pb-3">
|
|
<div class="space-y-1">
|
|
{% for n in p.notifications %}
|
|
<div class="flex items-start gap-1.5 text-xs">
|
|
<span class="badge badge-xs badge-ghost flex-shrink-0">{{ n.event }}</span>
|
|
<span class="text-base-content/60 truncate">
|
|
{% if n.files %}
|
|
{{ n.files | first }}
|
|
{% set fc = n.files | length %}
|
|
{% if fc > 1 %} +{{ fc - 1 }}{% endif %}
|
|
{% endif %}
|
|
</span>
|
|
<span class="text-base-content/30 ml-auto flex-shrink-0">{{ n.age_secs }}s</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<div class="flex flex-col items-center justify-center py-16 text-base-content/40">
|
|
<svg class="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
|
</svg>
|
|
<p class="text-sm">No projects registered.</p>
|
|
<a href="/ui/manage" class="btn btn-sm btn-ghost mt-3">Add a project</a>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Card modal -->
|
|
<dialog id="card-modal" class="modal">
|
|
<div class="modal-box w-11/12 max-w-lg">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h3 class="font-bold text-lg font-mono" id="card-modal-slug"></h3>
|
|
<p class="text-sm text-base-content/50 italic mt-0.5" id="card-modal-tagline"></p>
|
|
</div>
|
|
<form method="dialog">
|
|
<button class="btn btn-sm btn-circle btn-ghost">✕</button>
|
|
</form>
|
|
</div>
|
|
<div class="space-y-3 text-sm" id="card-modal-body"></div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
|
|
<script>
|
|
(function () {
|
|
var CARDS = {
|
|
{% for p in projects %}{% if p.card %}
|
|
"{{ p.slug }}": {
|
|
tagline: {{ p.card.tagline | json_encode | safe }},
|
|
description: {{ p.card.description | json_encode | safe }},
|
|
version: {{ p.card.version | json_encode | safe }},
|
|
status: {{ p.card.status | json_encode | safe }},
|
|
url: {{ p.card.url | json_encode | safe }},
|
|
tags: {{ p.card.tags | json_encode | safe }},
|
|
features: {{ p.card.features | json_encode | safe }},
|
|
},
|
|
{% endif %}{% endfor %}
|
|
};
|
|
|
|
window.openCard = function(slug) {
|
|
var c = CARDS[slug];
|
|
if (!c) return;
|
|
document.getElementById("card-modal-slug").textContent = slug;
|
|
document.getElementById("card-modal-tagline").textContent = c.tagline;
|
|
var body = document.getElementById("card-modal-body");
|
|
var html = "";
|
|
if (c.description) {
|
|
html += '<p class="text-base-content/80 leading-relaxed">' + c.description + '</p>';
|
|
}
|
|
var meta = [];
|
|
if (c.version) meta.push('<span class="badge badge-ghost badge-sm font-mono">v' + c.version + '</span>');
|
|
if (c.status) meta.push('<span class="badge badge-outline badge-sm">' + c.status + '</span>');
|
|
if (meta.length) html += '<div class="flex gap-2 flex-wrap">' + meta.join("") + '</div>';
|
|
if (c.features && c.features.length) {
|
|
html += '<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1.5">Features</p><ul class="space-y-1">';
|
|
c.features.forEach(function(f) {
|
|
html += '<li class="flex gap-2 text-xs text-base-content/70"><span class="text-primary flex-shrink-0">▸</span>' + f + '</li>';
|
|
});
|
|
html += '</ul></div>';
|
|
}
|
|
if (c.tags && c.tags.length) {
|
|
html += '<div class="flex flex-wrap gap-1.5">';
|
|
c.tags.forEach(function(t) {
|
|
html += '<span class="badge badge-xs badge-ghost font-mono">' + t + '</span>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
if (c.url) {
|
|
html += '<a href="' + c.url + '" target="_blank" rel="noopener" class="btn btn-xs btn-ghost gap-1 self-start border border-base-content/10">↗ ' + c.url + '</a>';
|
|
}
|
|
body.innerHTML = html;
|
|
document.getElementById("card-modal").showModal();
|
|
};
|
|
})();
|
|
</script>
|
|
<script>
|
|
(function () {
|
|
var TOTAL = {{ projects | length }};
|
|
window.filterProjects = function(q) {
|
|
var needle = q.trim().toLowerCase();
|
|
var cards = document.querySelectorAll('#project-grid [data-slug]');
|
|
var visible = 0;
|
|
cards.forEach(function(card) {
|
|
var hay = card.getAttribute('data-filter-text').toLowerCase();
|
|
var show = !needle || hay.indexOf(needle) !== -1;
|
|
card.style.display = show ? '' : 'none';
|
|
if (show) visible++;
|
|
});
|
|
var lbl = document.getElementById('project-count-label');
|
|
if (lbl) {
|
|
lbl.textContent = needle
|
|
? visible + ' of ' + TOTAL + ' project' + (TOTAL !== 1 ? 's' : '')
|
|
: TOTAL + ' project' + (TOTAL !== 1 ? 's' : '') + ' registered';
|
|
}
|
|
};
|
|
})();
|
|
</script>
|
|
{% endblock content %}
|