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:
parent
e699129064
commit
3a864aa84e
4 changed files with 180 additions and 69 deletions
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue