xtablo-source/backend/templates/app_layout.templ
Arthur Belleville 5d0c201e86
Some checks failed
backend-ci / Backend tests (pull_request) Failing after 53s
backend-ci / Backend tests (push) Failing after 1s
Some work
2026-05-23 17:26:01 +02:00

338 lines
15 KiB
Text

package templates
import (
"backend/internal/auth"
"backend/internal/db/sqlc"
"backend/internal/web/ui"
)
// SidebarNavIcon renders the SVG icon for the given sidebar icon kind.
// SVG paths ported verbatim from go-backend/internal/web/views/icons.templ SidebarIcon.
templ SidebarNavIcon(kind string) {
switch kind {
case "panels":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M3 9h18"></path>
<path d="M9 21V9"></path>
</svg>
case "tasks":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="5" width="6" height="6" rx="1"></rect>
<path d="m3 17 2 2 4-4"></path>
<path d="M13 6h8"></path>
<path d="M13 12h8"></path>
<path d="M13 18h8"></path>
</svg>
case "layers":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"></path>
<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"></path>
<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"></path>
</svg>
case "planning":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
<path d="M8 7v7"></path>
<path d="M12 7v4"></path>
<path d="M16 7v9"></path>
</svg>
case "chat":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
</svg>
case "files":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
</svg>
case "team":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
default:
<span></span>
}
}
// SidebarNavItemRow renders one nav list item with Tailwind utility classes.
templ SidebarNavItemRow(item sidebarNavItem) {
<div class={ navItemWrapperClass(item.Active) }>
if item.Href != "" && item.Href != "#" {
<a class="w-full" href={ templ.SafeURL(item.Href) }>
<div class="flex items-center gap-x-2.5 pl-2">
<span class={ navItemIconClass(item.Active) }>
@SidebarNavIcon(item.Icon)
</span>
<div class={ navItemLabelClass(item.Active) }>{ item.Label }</div>
</div>
</a>
} else {
<div class="w-full">
<div class="flex items-center gap-x-2.5 pl-2">
<span class={ navItemIconClass(item.Active) }>
@SidebarNavIcon(item.Icon)
</span>
<div class={ navItemLabelClass(item.Active) }>{ item.Label }</div>
</div>
</div>
}
</div>
}
// SidebarProjectsSection renders the projects list in the sidebar.
templ SidebarProjectsSection(tablos []sqlc.Tablo) {
<div class="sidebar-projects">
<hr role="separator"/>
<div class="sidebar-section-label">Projects</div>
<ul class="sidebar-project-list">
for _, tablo := range tablos {
<li>
<a href={ templ.SafeURL("/tablos/" + tablo.ID.String()) } class="sidebar-project-link">
if tablo.Color.Valid && tablo.Color.String != "" {
<span class="sidebar-project-icon" style={ "background-color: " + tablo.Color.String }></span>
} else {
<span class="sidebar-project-icon"></span>
}
<span class="sidebar-project-label">{ tablo.Title }</span>
</a>
</li>
}
</ul>
</div>
}
// SidebarOrganizationFooter renders the user/org info at the bottom of the sidebar.
// Per D-F01/D-F02/D-F03: shows user email initial as avatar, email as name, and logout form.
templ SidebarOrganizationFooter(user *auth.User, csrfToken string) {
<div class="sidebar-organization">
<div class="organization-button">
<span class="organization-avatar">{ string([]rune(user.Email)[:1]) }</span>
<span class="organization-copy">
<span class="organization-name">{ user.Email }</span>
</span>
</div>
<form method="POST" action="/logout">
@ui.CSRFField(csrfToken)
<button type="submit" class="text-sm">Log out</button>
</form>
</div>
}
// DashboardSidebar renders the full sidebar matching the production design.
// Uses Tailwind utility classes. Collapse button is revealed on group hover (D-09).
templ DashboardSidebar(activePath string, tablos []sqlc.Tablo, user *auth.User, csrfToken string) {
<aside class="dashboard-sidebar">
<nav aria-label="Main navigation" class="sidebar-nav-shell group isolate flex flex-col overflow-y-auto overflow-x-hidden bg-white border-r border-gray-200 h-full w-full sticky top-0">
<!-- Brand / logo -->
<div class="relative flex flex-col items-center px-2 py-3 w-full">
<a class="flex flex-col items-center gap-2 w-full" href="/" aria-label="Accueil XTablo">
<img alt="" class="w-16 h-16 rounded-lg" src="/static/logo_dark.png" width="64" height="64" decoding="async" fetchpriority="low"/>
<h1 class="text-lg font-bold text-gray-900 whitespace-nowrap">XTablo</h1>
</a>
<button
class="absolute top-2 right-2 size-5 p-1 text-gray-500 hover:text-gray-900 bg-white rounded-full shadow-md opacity-0 group-hover:opacity-100 hover:scale-110 transition-all duration-300"
aria-label="Toggle sidebar"
onclick="(function(){var s=document.querySelector('.dashboard-sidebar');var sh=document.querySelector('.dashboard-shell');if(s&&sh){s.classList.toggle('!w-16');sh.classList.toggle('sidebar-is-collapsed');}})()"
type="button"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/></svg>
</button>
</div>
<!-- Primary nav -->
<nav class="flex flex-1 flex-col" aria-label="Primary navigation">
<ul role="list" class="grid py-3">
for _, item := range sidebarPrimaryNavItems(activePath) {
if item.DividerBefore {
<li class="my-2"><hr role="separator" class="border-gray-200"/></li>
}
<li>
@SidebarNavItemRow(item)
</li>
}
</ul>
<!-- Projects section -->
<div class="px-2 pb-2">
<hr role="separator" class="border-gray-200 mb-3"/>
<div class="px-2 mb-2">
<span class="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">Projets</span>
</div>
<ul class="space-y-0.5">
for _, tablo := range tablos {
<li>
<a class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900" href={ templ.SafeURL("/tablos/" + tablo.ID.String()) }>
<span class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center border border-gray-300">
if tablo.Color.Valid && tablo.Color.String != "" {
<span class="w-3 h-3 rounded-full" style={ "background-color: " + tablo.Color.String }></span>
} else {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5 text-gray-500" aria-hidden="true"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>
}
</span>
<span class="truncate flex-1">{ tablo.Title }</span>
</a>
</li>
}
</ul>
</div>
<!-- Feedback + separator at bottom -->
<ul role="list" class="mt-auto grid py-1">
<li>
<div class="flex w-full px-2.5 py-2 rounded-md cursor-pointer hover:bg-gray-100 font-medium">
<a class="w-full" href="#">
<div class="flex items-center gap-x-2.5 pl-2">
<span class="[&>svg]:w-5 [&>svg]:h-5 text-gray-500">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>
</span>
<div class="text-base font-normal text-gray-500">Feedback</div>
</div>
</a>
</div>
</li>
<li class="my-2"><hr role="separator" class="border-gray-200"/></li>
</ul>
</nav>
<!-- Org footer -->
<div class="flex flex-col px-1 pb-1.5 w-full gap-1">
<button class="flex items-center justify-start hover:bg-gray-100 w-full h-auto pl-2 py-1.5 gap-1 rounded-md text-sm" aria-label="User menu" type="button">
<span class="relative flex shrink-0 overflow-hidden rounded-full size-7 bg-purple-100 items-center justify-center">
<span class="text-[#804EEC] font-semibold text-sm">{ string([]rune(user.Email)[:1]) }</span>
</span>
<div class="flex flex-col items-start ml-1">
<p class="text-sm text-gray-700 font-medium truncate max-w-[7rem]">{ user.Email }</p>
<p class="text-xs text-gray-500">1 membre</p>
</div>
</button>
</div>
</nav>
</aside>
}
// PageHeader renders the top bar matching the production design:
// left (search input), right (bell placeholder + avatar dropdown).
// Breadcrumb data is retained in AppLayout params for downstream phases but not shown visually.
// The avatar dropdown uses native HTML details/summary — no Alpine.js (D-06).
templ PageHeader(pageTitle string, breadcrumb []BreadcrumbItem, headerActions templ.Component, user *auth.User, csrfToken string) {
<header class="page-header h-[75px] flex items-center justify-between px-6 gap-4 border-b border-[#EAECF0] bg-white shrink-0">
<!-- Hidden breadcrumb for screen readers and test assertions -->
<nav class="breadcrumb sr-only" aria-label="Breadcrumb">
for i, crumb := range breadcrumb {
if i > 0 {
<span aria-hidden="true">/</span>
}
if crumb.Href != "" {
<a href={ templ.SafeURL(crumb.Href) }>{ crumb.Label }</a>
} else {
<span aria-current="page">{ crumb.Label }</span>
}
}
if len(breadcrumb) == 0 {
<span aria-current="page">{ pageTitle }</span>
}
</nav>
<!-- LEFT: search input -->
<div class="relative flex-1 max-w-sm">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<label class="visually-hidden" for="global-search">Rechercher</label>
<input
id="global-search"
name="q"
type="search"
autocomplete="off"
placeholder="Rechercher…"
class="w-full pl-9 pr-4 py-2 bg-transparent border border-[#EAECF0] rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled
aria-disabled="true"
/>
</div>
<!-- RIGHT: bell + avatar -->
<div class="flex items-center gap-3">
<!-- Bell placeholder -->
<button
class="relative w-10 h-10 border border-[#EAECF0] rounded-[8px] text-[#0C111D] hover:bg-gray-100 flex items-center justify-center"
aria-label="Notifications (coming soon)"
type="button"
disabled
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5" aria-hidden="true">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"></path>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path>
</svg>
</button>
<!-- Avatar dropdown (details/summary, D-06) -->
<details class="header-avatar-menu relative">
<summary class="flex items-center gap-2 p-1 hover:bg-gray-100 rounded-[8px] cursor-pointer list-none [&::-webkit-details-marker]:hidden" aria-label={ "Menu utilisateur — " + user.Email }>
<span class="relative flex size-10 shrink-0 overflow-hidden rounded-full bg-purple-100 items-center justify-center">
<span class="text-[#804EEC] font-semibold text-sm">{ string([]rune(user.Email)[:1]) }</span>
</span>
</summary>
<div class="header-avatar-dropdown absolute right-0 top-full mt-1 w-56 rounded-lg border border-gray-200 bg-white shadow-lg z-50 py-1">
<div class="px-3 py-2">
<p class="text-sm font-medium text-gray-900 truncate">{ user.Email }</p>
<p class="text-xs text-gray-500">1 membre</p>
</div>
<hr class="border-gray-100"/>
<a href="/settings" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Paramètres</a>
<hr class="border-gray-100"/>
<form method="POST" action="/logout" class="avatar-dropdown-logout-form">
@ui.CSRFField(csrfToken)
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-gray-50">Se déconnecter</button>
</form>
</div>
</details>
</div>
<!-- Per-page header actions slot -->
if headerActions != nil {
<div class="flex items-center gap-2">
@headerActions
</div>
}
</header>
}
// AppLayout is the authenticated HTML shell for all dashboard pages.
// It replaces Layout for all authenticated routes in Phase 15+.
//
// Differences from Layout:
// - Uses .dashboard-shell grid with sidebar + main content
// - DashboardSidebar renders nav, projects, and org footer
// - main#app-main-content.dashboard-main wraps page children
// - Scripts: htmx.min.js, sortable.min.js, discussion-sse.js (per D-L04)
templ AppLayout(title string, user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, pageTitle string, breadcrumb []BreadcrumbItem, headerActions templ.Component) {
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="color-scheme" content="light"/>
<title>{ title }</title>
<link rel="stylesheet" href="/static/tailwind.css"/>
</head>
<body>
<a href="#app-main-content" class="skip-link visually-hidden">Aller au contenu principal</a>
<div class="dashboard-shell">
@DashboardSidebar(activePath, tablos, user, csrfToken)
<main id="app-main-content" class="dashboard-main" tabindex="-1">
@PageHeader(pageTitle, breadcrumb, headerActions, user, csrfToken)
<div class="dashboard-main-content">
{ children... }
</div>
</main>
</div>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/sortable.min.js" defer></script>
<script src="/static/discussion-sse.js" defer></script>
<script>
document.addEventListener('click', function(e) {
document.querySelectorAll('details.header-avatar-menu').forEach(function(d) {
if (!d.contains(e.target)) { d.removeAttribute('open'); }
});
});
</script>
</body>
</html>
}