338 lines
15 KiB
Text
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>
|
|
}
|