feat(19): revamp tablos page — filter tabs, view toggle, card click nav

- Dashboard: French header "Mes Projets", underline-tab view toggle (grid/list), filter tabs (Tous/Pas commencé/En cours/Terminé) with JS client-side filtering
- Cards: display status derived from progress (À faire/En cours/Terminé), rounded-xl p-6, w-8 h-8 avatar, green-500 progress bar, dashed "Créé le" footer
- Click on card/row navigates to /tablos/{uuid} via event delegation (delete zone stops propagation)
- List view: single-column grid, rows show status + title + date + task count
- CSS: .view-tab and .filter-tab with .is-active state

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-05-17 22:11:31 +02:00
parent e699129064
commit 3a864aa84e
No known key found for this signature in database
4 changed files with 180 additions and 69 deletions

View file

@ -887,6 +887,65 @@
#tablos-list[data-view="list"] .tablo-list-row {
display: flex;
align-items: center;
}
/* Single-column layout in list view */
#tablos-list[data-view="list"] {
grid-template-columns: 1fr;
gap: 0.25rem;
}
/* ── View toggle tabs ─────────────────────────────────────── */
.view-tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid transparent;
color: var(--color-text-muted, #6b7280);
font-size: 0.875rem;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.view-tab:hover {
color: #374151;
}
.view-tab.is-active {
border-bottom-color: #9333ea;
color: #9333ea;
font-weight: 600;
}
/* ── Filter tabs ───────────────────────────────────────────── */
.filter-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
border: 1px solid #EAECF0;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background: white;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-tab:hover {
background: #f9fafb;
}
.filter-tab.is-active {
border-color: #9333ea;
background: #faf5ff;
color: #9333ea;
}
/* ============================================================

File diff suppressed because one or more lines are too long

View file

@ -47,6 +47,18 @@ func isActivePath(activePath string, href string) bool {
return strings.TrimSpace(activePath) != "" && activePath == href
}
// tabloDisplayStatus derives a display status from progress for client-side filtering.
// "pas-commence" = 0% (no tasks or none done), "en-cours" = 1-99%, "termine" = 100%.
func tabloDisplayStatus(progress, totalTasks int) string {
if totalTasks == 0 || progress == 0 {
return "pas-commence"
}
if progress >= 100 {
return "termine"
}
return "en-cours"
}
// sidebarPrimaryNavItems returns the ordered sidebar nav items.
// French labels match the production app. DividerBefore adds an <hr> separator before the item.
func sidebarPrimaryNavItems(activePath string) []sidebarNavItem {

View file

@ -5,49 +5,53 @@ import (
"backend/internal/db/sqlc"
"backend/internal/web/ui"
"strconv"
"strings"
)
// TablosDashboard renders the root authenticated dashboard with sidebar AppLayout.
// Shows a project-card grid (or empty state) for the user's tablos.
// UI-SPEC §1 Interaction Contract — GET /.
templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tablos []sqlc.Tablo, cards []TabloCardView, pageTitle string, breadcrumb []BreadcrumbItem) {
@AppLayout("Tablos — Xtablo", user, csrfToken, activePath, tablos, pageTitle, breadcrumb, nil) {
<section class="overview-section">
<div class="overview-section-heading">
<h3>Your Tablos</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="view-toggle-btn"
onclick="var el=document.getElementById('tablos-list');el.dataset.view=el.dataset.view==='list'?'grid':'list'"
aria-label="Toggle list view"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="8" x2="21" y1="6" y2="6"></line>
<line x1="8" x2="21" y1="12" y2="12"></line>
<line x1="8" x2="21" y1="18" y2="18"></line>
<line x1="3" x2="3.01" y1="6" y2="6"></line>
<line x1="3" x2="3.01" y1="12" y2="12"></line>
<line x1="3" x2="3.01" y1="18" y2="18"></line>
</svg>
</button>
@ui.Button(ui.ButtonProps{
Label: "New tablo",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/new",
"hx-target": "#create-form-slot",
"hx-swap": "innerHTML",
},
})
</div>
<div class="px-4 pt-8 pb-6">
<!-- Header row -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<h1 class="text-2xl font-bold text-gray-900">Mes Projets</h1>
@ui.Button(ui.ButtonProps{
Label: "Nouveau projet",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "button",
Attrs: templ.Attributes{
"hx-get": "/tablos/new",
"hx-target": "#create-form-slot",
"hx-swap": "innerHTML",
},
})
</div>
<div id="create-form-slot"></div>
<div id="tablos-list" class="project-grid" data-view="grid">
<!-- View toggle tabs -->
<div class="flex items-center gap-6 mb-6 border-b border-[#EAECF0]">
<button type="button" class="view-tab is-active" data-view-btn="grid" onclick="setTablosView('grid')">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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="M3 15h18"></path><path d="M9 3v18"></path><path d="M15 3v18"></path></svg>
<span class="font-medium">Vue en grille</span>
</button>
<button type="button" class="view-tab" data-view-btn="list" onclick="setTablosView('list')">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h.01"></path><path d="M3 18h.01"></path><path d="M3 6h.01"></path><path d="M8 12h13"></path><path d="M8 18h13"></path><path d="M8 6h13"></path></svg>
<span class="font-medium">Vue en liste</span>
</button>
</div>
<!-- Filter buttons -->
<div class="flex items-center gap-2 flex-wrap mb-6">
<button type="button" class="filter-tab is-active" data-filter-btn="tous" onclick="filterTablos('tous')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
Tous
</button>
<button type="button" class="filter-tab" data-filter-btn="pas-commence" onclick="filterTablos('pas-commence')">Pas commencé</button>
<button type="button" class="filter-tab" data-filter-btn="en-cours" onclick="filterTablos('en-cours')">En cours</button>
<button type="button" class="filter-tab" data-filter-btn="termine" onclick="filterTablos('termine')">Terminé</button>
</div>
<!-- Card/list grid -->
<div id="tablos-list" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6" data-view="grid">
if len(cards) == 0 {
@TablosEmptyState()
} else {
@ -56,7 +60,30 @@ templ TablosDashboard(user *auth.User, csrfToken string, activePath string, tabl
}
}
</div>
</section>
</div>
<script>
function setTablosView(v) {
document.getElementById('tablos-list').dataset.view = v;
document.querySelectorAll('[data-view-btn]').forEach(function(b) {
b.classList.toggle('is-active', b.dataset.viewBtn === v);
});
}
function filterTablos(s) {
document.querySelectorAll('#tablos-list article').forEach(function(el) {
el.style.display = (s === 'tous' || el.dataset.displayStatus === s) ? '' : 'none';
});
document.querySelectorAll('[data-filter-btn]').forEach(function(b) {
b.classList.toggle('is-active', b.dataset.filterBtn === s);
});
}
// Card click → navigate to tablo detail (stop on delete zone)
document.getElementById('tablos-list').addEventListener('click', function(e) {
var article = e.target.closest('article[data-href]');
if (article && !e.target.closest('.tablo-delete-zone')) {
window.location = article.dataset.href;
}
});
</script>
}
}
@ -91,17 +118,22 @@ templ TablosEmptyState() {
// Guards color rendering against null pgtype.Text values (Pitfall 6).
// Uses .Time accessor on pgtype.Timestamptz (Pitfall 6).
templ TabloProjectCard(card TabloCardView, csrfToken string) {
<article id={ "tablo-" + card.Tablo.ID.String() } class="tablo-card-wrapper">
<article
id={ "tablo-" + card.Tablo.ID.String() }
class="tablo-card-wrapper"
data-display-status={ tabloDisplayStatus(card.Progress, card.TotalTasks) }
data-href={ "/tablos/" + card.Tablo.ID.String() }
>
<!-- Card view (default: visible in grid layout) -->
<div class="bg-white rounded-2xl p-4 border border-[#EAECF0] hover:shadow-md transition-shadow cursor-pointer project-card">
<!-- Top row: status badge + delete button -->
<div class="bg-white rounded-xl p-6 border border-[#EAECF0] hover:shadow-md transition-shadow cursor-pointer project-card">
<!-- Top row: display-status badge + delete button -->
<div class="flex items-start justify-between mb-4">
if len(card.Tablo.Status) > 0 {
<span class="px-3 py-1 rounded-full text-xs font-medium border bg-purple-50 text-purple-600 border-purple-200">
{ strings.ToUpper(card.Tablo.Status[:1]) + card.Tablo.Status[1:] }
</span>
if tabloDisplayStatus(card.Progress, card.TotalTasks) == "termine" {
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-50 text-green-600 border border-green-200">Terminé</span>
} else if tabloDisplayStatus(card.Progress, card.TotalTasks) == "en-cours" {
<span class="px-3 py-1 rounded-full text-sm font-medium bg-[#FFF4E2] text-[#DB9729] border border-[#DB9729]">En cours</span>
} else {
<span></span>
<span class="px-3 py-1 rounded-full text-sm font-medium bg-blue-50 text-blue-600 border border-blue-200">À faire</span>
}
<div class="tablo-delete-zone">
@ui.IconButton(ui.IconButtonProps{
@ -121,51 +153,59 @@ templ TabloProjectCard(card TabloCardView, csrfToken string) {
<!-- Avatar + title -->
<div class="flex items-center gap-3 mb-4">
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
<div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 overflow-hidden" style={ "background-color: " + card.Tablo.Color.String }>
<span class="text-white font-bold text-lg">
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden" style={ "background-color: " + card.Tablo.Color.String }>
<span class="text-white font-bold">
if len(card.Tablo.Title) > 0 {
{ string([]rune(card.Tablo.Title)[0:1]) }
}
</span>
</div>
} else {
<div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 overflow-hidden bg-purple-500">
<span class="text-white font-bold text-lg">
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden bg-blue-500">
<span class="text-white font-bold">
if len(card.Tablo.Title) > 0 {
{ string([]rune(card.Tablo.Title)[0:1]) }
}
</span>
</div>
}
<h3 class="font-semibold text-gray-900 flex-1 truncate">{ card.Tablo.Title }</h3>
<h3 class="text-base font-semibold text-gray-900 flex-1 line-clamp-2">{ card.Tablo.Title }</h3>
</div>
<!-- Date row -->
<div class="flex items-center gap-2 text-sm text-gray-500 mb-4">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<span>{ card.Tablo.CreatedAt.Time.Format("Jan 2, 2006") }</span>
<div class="flex items-center gap-2 text-gray-600 mb-4">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 shrink-0" aria-hidden="true"><path d="M8 2v4"></path><path d="M16 2v4"></path><rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg>
<span class="text-sm">{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }</span>
</div>
<!-- Progress row -->
<div class="mb-3 project-card-progress-row">
<div class="flex items-center justify-between text-sm mb-2">
<span class="text-gray-600">Progression:</span>
<span class="font-semibold text-gray-900">{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) } completed tasks</span>
<div class="mb-4 project-card-progress-row">
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-gray-600">Progression :</span>
<span class="text-sm font-semibold text-gray-900">{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) } completed tasks</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2 project-progress-track">
<div class="h-2 rounded-full bg-purple-500 project-card-progress-bar" style={ "width: " + strconv.Itoa(card.Progress) + "%" }></div>
<div class="w-full bg-gray-200 rounded-full h-2 project-progress-track">
<div class="bg-green-500 h-2 rounded-full project-card-progress-bar" style={ "width: " + strconv.Itoa(card.Progress) + "%" }></div>
</div>
</div>
<!-- Created footer -->
<div class="pt-4 border-t border-dashed border-[#D0D5DD]">
<span class="text-sm text-gray-500">Créé le <span class="font-semibold text-gray-900">{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }</span></span>
</div>
</div>
<!-- List row (hidden by default, shown when data-view="list") -->
<div class="tablo-list-row">
if len(card.Tablo.Status) > 0 {
<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-purple-50 text-purple-600 border-purple-200">
{ strings.ToUpper(card.Tablo.Status[:1]) + card.Tablo.Status[1:] }
</span>
}
<span class="tablo-list-row-title">{ card.Tablo.Title }</span>
<span class="tablo-list-row-meta">{ card.Tablo.CreatedAt.Time.Format("Jan 2, 2006") }</span>
<span class="tablo-list-row-meta">{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) }</span>
<div class="tablo-delete-zone">
<div class="tablo-list-row" onclick="event.stopPropagation()">
<a class="flex items-center gap-4 w-full px-4 py-3 hover:bg-gray-50" href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) }>
if tabloDisplayStatus(card.Progress, card.TotalTasks) == "termine" {
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-600 border border-green-200 shrink-0">Terminé</span>
} else if tabloDisplayStatus(card.Progress, card.TotalTasks) == "en-cours" {
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-[#FFF4E2] text-[#DB9729] border border-[#DB9729] shrink-0">En cours</span>
} else {
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-200 shrink-0">À faire</span>
}
<span class="tablo-list-row-title flex-1">{ card.Tablo.Title }</span>
<span class="tablo-list-row-meta shrink-0">{ card.Tablo.CreatedAt.Time.Format("2 Jan 2006") }</span>
<span class="tablo-list-row-meta shrink-0">{ strconv.Itoa(card.DoneTasks) }/{ strconv.Itoa(card.TotalTasks) }</span>
</a>
<div class="tablo-delete-zone shrink-0" onclick="event.stopPropagation()">
@ui.IconButton(ui.IconButtonProps{
Label: "Delete tablo",
Icon: "trash",