752 lines
32 KiB
Text
752 lines
32 KiB
Text
package templates
|
|
|
|
import (
|
|
"backend/internal/auth"
|
|
"backend/internal/db/sqlc"
|
|
"backend/internal/web/ui"
|
|
"strconv"
|
|
)
|
|
|
|
// TablosDashboard renders the root authenticated dashboard with sidebar AppLayout.
|
|
// Shows a project-card grid (or empty state) for the user's tablos.
|
|
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) {
|
|
<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>
|
|
<!-- 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="active" onclick="filterTablos('active')">Actif</button>
|
|
<button type="button" class="filter-tab" data-filter-btn="archived" onclick="filterTablos('archived')">Archivé</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 {
|
|
for _, card := range cards {
|
|
@TabloProjectCard(card, csrfToken)
|
|
}
|
|
}
|
|
</div>
|
|
</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>
|
|
}
|
|
}
|
|
|
|
// TablosEmptyState renders the empty-state copy when a user has no tablos.
|
|
// Copy strings are locked by UI-SPEC Copywriting Contract.
|
|
// Uses ui.EmptyState for consistent styling across the app (Phase 13).
|
|
templ TablosEmptyState() {
|
|
@ui.EmptyState(ui.EmptyStateProps{
|
|
Title: "No tablos yet",
|
|
Description: "Create your first tablo to get started.",
|
|
Action: 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",
|
|
},
|
|
}),
|
|
})
|
|
}
|
|
|
|
// TabloProjectCard renders a single tablo as a dual-element card+row wrapper.
|
|
// The outer article.tablo-card-wrapper contains both a .project-card (grid view)
|
|
// and a .tablo-list-row (list view, hidden by default).
|
|
// Matches production design: status badge top-left, delete button top-right,
|
|
// colored avatar with initial letter, title, date, and progress bar.
|
|
// Uses display:contents on the wrapper so it is transparent to grid/flex layout.
|
|
// 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"
|
|
data-display-status={ card.Tablo.Status }
|
|
data-href={ "/tablos/" + card.Tablo.ID.String() }
|
|
>
|
|
<!-- Card view (default: visible in grid layout) -->
|
|
<div class="bg-white rounded-xl p-5 border border-[#EAECF0] hover:shadow-md transition-shadow cursor-pointer project-card flex flex-col gap-3">
|
|
<!-- Row 1: icon + title -->
|
|
<div class="flex items-center gap-3">
|
|
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style={ "background-color: " + card.Tablo.Color.String }>
|
|
<span class="text-white font-bold text-sm">
|
|
if len(card.Tablo.Title) > 0 {
|
|
{ string([]rune(card.Tablo.Title)[0:1]) }
|
|
}
|
|
</span>
|
|
</div>
|
|
} else {
|
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-blue-500">
|
|
<span class="text-white font-bold text-sm">
|
|
if len(card.Tablo.Title) > 0 {
|
|
{ string([]rune(card.Tablo.Title)[0:1]) }
|
|
}
|
|
</span>
|
|
</div>
|
|
}
|
|
<h3 class="text-base font-semibold text-gray-900 flex-1 line-clamp-2">{ card.Tablo.Title }</h3>
|
|
</div>
|
|
<!-- Row 2: status badge + edit + delete (same line) -->
|
|
<div class="flex items-center justify-between pt-1 border-t border-gray-100">
|
|
if card.Tablo.Status == "archived" {
|
|
<span class="px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600 border border-gray-200">Archivé</span>
|
|
} else {
|
|
<span class="px-2.5 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600 border border-green-200">Actif</span>
|
|
}
|
|
<div class="flex items-center gap-1">
|
|
<a
|
|
href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) }
|
|
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors"
|
|
aria-label="Edit tablo"
|
|
onclick="event.stopPropagation()"
|
|
>
|
|
<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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
|
</a>
|
|
<div class="tablo-delete-zone">
|
|
@ui.IconButton(ui.IconButtonProps{
|
|
Label: "Delete tablo",
|
|
Icon: "trash",
|
|
Variant: ui.IconButtonVariantDanger,
|
|
Tone: ui.IconButtonToneGhost,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"hx-get": "/tablos/" + card.Tablo.ID.String() + "/delete-confirm",
|
|
"hx-target": "closest .tablo-delete-zone",
|
|
"hx-swap": "outerHTML",
|
|
},
|
|
})
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- List row (hidden by default, shown when data-view="list") -->
|
|
<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",
|
|
Variant: ui.IconButtonVariantDanger,
|
|
Tone: ui.IconButtonToneGhost,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"hx-get": "/tablos/" + card.Tablo.ID.String() + "/delete-confirm",
|
|
"hx-target": "closest .tablo-delete-zone",
|
|
"hx-swap": "outerHTML",
|
|
},
|
|
})
|
|
</div>
|
|
</div>
|
|
</article>
|
|
}
|
|
|
|
// TabloCard renders a single tablo as a ui.Card on the dashboard.
|
|
// Guards description and color rendering against null pgtype.Text values (Pitfall 6).
|
|
// Delegates delete-zone rendering to TabloDeleteButtonFragment (single source of truth).
|
|
templ TabloCard(card TabloCardView, csrfToken string) {
|
|
<div id={ "tablo-" + card.Tablo.ID.String() }>
|
|
@ui.Card(ui.CardProps{Body: tabloCardBody(card, csrfToken)})
|
|
</div>
|
|
}
|
|
|
|
templ tabloCardBody(card TabloCardView, csrfToken string) {
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<h2 class="text-xl font-semibold leading-snug">{ card.Tablo.Title }</h2>
|
|
@DiscussionUnreadBadge(card.DiscussionUnreadCount)
|
|
</div>
|
|
if card.Tablo.Description.Valid && card.Tablo.Description.String != "" {
|
|
<p class="mt-2 text-base text-slate-600">{ card.Tablo.Description.String }</p>
|
|
}
|
|
if card.Tablo.Color.Valid && card.Tablo.Color.String != "" {
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<span
|
|
class="inline-block w-2.5 h-2.5 rounded-full"
|
|
style={ "background-color: " + card.Tablo.Color.String }
|
|
></span>
|
|
<span class="text-sm text-slate-500">{ card.Tablo.Color.String }</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
@TabloDeleteButtonFragment(card.Tablo, csrfToken)
|
|
</div>
|
|
<div class="mt-4">
|
|
<a href={ templ.SafeURL("/tablos/" + card.Tablo.ID.String()) } class="text-sm font-medium text-blue-600 hover:underline">View</a>
|
|
</div>
|
|
}
|
|
|
|
templ DiscussionUnreadBadge(count int64) {
|
|
if count > 0 {
|
|
<span class="inline-flex min-w-6 items-center justify-center rounded-full border border-blue-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold leading-none text-blue-700" aria-label={ DiscussionUnreadAriaLabel(count) }>{ DiscussionUnreadDisplay(count) }</span>
|
|
}
|
|
}
|
|
|
|
// TabloCreateFormFragment renders the inline create form loaded into #create-form-slot
|
|
// via HTMX. Falls back to a plain POST /tablos for non-JS paths.
|
|
// UI-SPEC §2 Interaction Contract — GET /tablos/new + POST /tablos.
|
|
templ TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) {
|
|
<form
|
|
id="create-form"
|
|
method="POST"
|
|
action="/tablos"
|
|
hx-post="/tablos"
|
|
hx-target="#create-form-slot"
|
|
hx-swap="innerHTML"
|
|
class="mb-6 space-y-4 rounded border border-slate-200 bg-slate-50 p-6"
|
|
>
|
|
@ui.CSRFField(csrfToken)
|
|
@GeneralError(errs.General)
|
|
<h2 class="text-xl font-semibold leading-snug">Create a tablo</h2>
|
|
<div>
|
|
<label for="title" class="block text-sm font-medium text-slate-700">Title</label>
|
|
<input
|
|
id="title"
|
|
type="text"
|
|
name="title"
|
|
value={ form.Title }
|
|
required
|
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
placeholder="My tablo"
|
|
/>
|
|
@FieldError(errs.Title)
|
|
</div>
|
|
<div>
|
|
<label for="description" class="block text-sm font-medium text-slate-700">Description <span class="text-slate-400">(optional)</span></label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
rows="3"
|
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
placeholder="What is this tablo for?"
|
|
>{ form.Description }</textarea>
|
|
</div>
|
|
<div>
|
|
<label for="color" class="block text-sm font-medium text-slate-700">Color <span class="text-slate-400">(optional)</span></label>
|
|
<input
|
|
id="color"
|
|
type="text"
|
|
name="color"
|
|
value={ form.Color }
|
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
placeholder="#6366f1"
|
|
/>
|
|
@FieldError(errs.Color)
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Create tablo",
|
|
Variant: ui.ButtonVariantDefault,
|
|
Tone: ui.ButtonToneSolid,
|
|
Size: ui.SizeMD,
|
|
Type: "submit",
|
|
})
|
|
<a href="/" class="text-sm text-slate-600 hover:underline">Cancel</a>
|
|
</div>
|
|
</form>
|
|
}
|
|
|
|
// TabloCardWithOOBFormClear renders a TabloCard as the primary swap target
|
|
// AND an OOB element that clears #create-form-slot in the same response.
|
|
// The OOB div MUST be a top-level sibling of TabloCard — NOT nested (Pitfall 5).
|
|
// HTMX applies the primary swap (HX-Retarget: #tablos-list, afterbegin) AND
|
|
// the OOB swap (#create-form-slot → empty) from a single response.
|
|
templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
|
|
@TabloCard(TabloCardFromTablo(tablo), csrfToken)
|
|
<div id="create-form-slot" hx-swap-oob="true"></div>
|
|
}
|
|
|
|
// TabloDetailPage renders the full detail page for a single tablo with a 3-tab layout.
|
|
// Tabs: Overview / Tasks / Files. activeTab selects the initially rendered tab content.
|
|
// files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs).
|
|
// activePath and sidebarTablos drive the AppLayout sidebar.
|
|
// UI-SPEC §3 Interaction Contract — GET /tablos/{id}.
|
|
// D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url.
|
|
templ TabloDetailPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, discussion DiscussionTabData, activeTab string, pageTitle string, breadcrumb []BreadcrumbItem) {
|
|
@AppLayout("Tablos — Xtablo", user, csrfToken, activePath, sidebarTablos, pageTitle, breadcrumb, nil) {
|
|
<!-- Header: project-card-top layout with color avatar, title zone, and action controls -->
|
|
<div class="px-4 pt-4">
|
|
<div class="project-card-top">
|
|
<div class="project-card-title-row">
|
|
if tablo.Color.Valid && tablo.Color.String != "" {
|
|
<span class="project-avatar" style={ "background-color: " + tablo.Color.String }>
|
|
if len(tablo.Title) > 0 {
|
|
{ string([]rune(tablo.Title)[0:1]) }
|
|
}
|
|
</span>
|
|
} else {
|
|
<span class="project-avatar">
|
|
if len(tablo.Title) > 0 {
|
|
{ string([]rune(tablo.Title)[0:1]) }
|
|
}
|
|
</span>
|
|
}
|
|
<div class="tablo-title-zone">
|
|
@TabloTitleDisplay(tablo, csrfToken)
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<a
|
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/discussion") }
|
|
hx-get={ "/tablos/" + tablo.ID.String() + "/discussion" }
|
|
hx-target="#tab-content"
|
|
hx-swap="innerHTML"
|
|
hx-push-url={ "/tablos/" + tablo.ID.String() + "/discussion" }
|
|
>
|
|
@ui.IconButton(ui.IconButtonProps{
|
|
Label: "Discussion",
|
|
Icon: "chat",
|
|
Variant: ui.IconButtonVariantNeutral,
|
|
Tone: ui.IconButtonToneGhost,
|
|
Type: "button",
|
|
})
|
|
</a>
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Invite Member",
|
|
Variant: ui.ButtonVariantDefault,
|
|
Tone: ui.ButtonToneSoft,
|
|
Size: ui.SizeMD,
|
|
Type: "button",
|
|
})
|
|
<div class="tablo-delete-zone">
|
|
@ui.IconButton(ui.IconButtonProps{
|
|
Label: "Delete tablo",
|
|
Icon: "trash",
|
|
Variant: ui.IconButtonVariantDanger,
|
|
Tone: ui.IconButtonToneGhost,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm",
|
|
"hx-target": "closest .tablo-delete-zone",
|
|
"hx-swap": "outerHTML",
|
|
},
|
|
})
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Metadata row: created date, status, progress -->
|
|
<div class="tablo-metadata-row">
|
|
<div class="tablo-metadata-date">
|
|
<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="4" rx="2" ry="2"></rect><line x1="16" x2="16" y1="2" y2="6"></line><line x1="8" x2="8" y1="2" y2="6"></line><line x1="3" x2="21" y1="10" y2="10"></line></svg>
|
|
<span>Created</span>
|
|
<span>{ tablo.CreatedAt.Time.Format("Jan 2, 2006") }</span>
|
|
</div>
|
|
@ui.Badge(ui.BadgeProps{Label: "In progress", Variant: ui.BadgeVariantPrimary})
|
|
<div class="project-progress-track">
|
|
<div class="project-progress-bar" style="width: 0%;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Sticky tab navigation (D-07, D-08) -->
|
|
<div class="w-full bg-white dark:bg-background sticky top-0 z-40">
|
|
<div class="py-2 px-4">
|
|
<nav class="tab-nav">
|
|
<a
|
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
|
|
hx-get={ "/tablos/" + tablo.ID.String() }
|
|
hx-target="#tab-content"
|
|
hx-swap="innerHTML"
|
|
hx-push-url={ "/tablos/" + tablo.ID.String() }
|
|
if activeTab == "overview" || activeTab == "" {
|
|
class="tab-nav-item is-active"
|
|
} else {
|
|
class="tab-nav-item"
|
|
}
|
|
>
|
|
<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"><rect width="7" height="9" x="3" y="3" rx="1"></rect><rect width="7" height="5" x="14" y="3" rx="1"></rect><rect width="7" height="9" x="14" y="12" rx="1"></rect><rect width="7" height="5" x="3" y="16" rx="1"></rect></svg>
|
|
Overview
|
|
</a>
|
|
<a
|
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/tasks") }
|
|
hx-get={ "/tablos/" + tablo.ID.String() + "/tasks" }
|
|
hx-target="#tab-content"
|
|
hx-swap="innerHTML"
|
|
hx-push-url={ "/tablos/" + tablo.ID.String() + "/tasks" }
|
|
if activeTab == "tasks" {
|
|
class="tab-nav-item is-active"
|
|
} else {
|
|
class="tab-nav-item"
|
|
}
|
|
>
|
|
<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"><path d="m3 17 2 2 4-4"></path><path d="m3 7 2 2 4-4"></path><path d="M13 6h8"></path><path d="M13 12h8"></path><path d="M13 18h8"></path></svg>
|
|
Tasks
|
|
</a>
|
|
<a
|
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/files") }
|
|
hx-get={ "/tablos/" + tablo.ID.String() + "/files" }
|
|
hx-target="#tab-content"
|
|
hx-swap="innerHTML"
|
|
hx-push-url={ "/tablos/" + tablo.ID.String() + "/files" }
|
|
if activeTab == "files" {
|
|
class="tab-nav-item is-active"
|
|
} else {
|
|
class="tab-nav-item"
|
|
}
|
|
>
|
|
<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"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"></path></svg>
|
|
Files
|
|
</a>
|
|
<a
|
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/discussion") }
|
|
hx-get={ "/tablos/" + tablo.ID.String() + "/discussion" }
|
|
hx-target="#tab-content"
|
|
hx-swap="innerHTML"
|
|
hx-push-url={ "/tablos/" + tablo.ID.String() + "/discussion" }
|
|
if activeTab == "discussion" {
|
|
class="tab-nav-item is-active"
|
|
} else {
|
|
class="tab-nav-item"
|
|
}
|
|
>
|
|
<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"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path></svg>
|
|
Discussion
|
|
</a>
|
|
<a
|
|
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/events") }
|
|
hx-get={ "/tablos/" + tablo.ID.String() + "/events" }
|
|
hx-target="#tab-content"
|
|
hx-swap="innerHTML"
|
|
hx-push-url={ "/tablos/" + tablo.ID.String() + "/events" }
|
|
if activeTab == "events" {
|
|
class="tab-nav-item is-active"
|
|
} else {
|
|
class="tab-nav-item"
|
|
}
|
|
>
|
|
<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"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"></rect><line x1="16" x2="16" y1="2" y2="6"></line><line x1="8" x2="8" y1="2" y2="6"></line><line x1="3" x2="21" y1="10" y2="10"></line></svg>
|
|
Events
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
<!-- Tab content area — HTMX tab switches target this div -->
|
|
<div id="tab-content" class="px-4 sm:px-6 pt-6 pb-8">
|
|
if activeTab == "tasks" {
|
|
@TasksTabFragment(tablo, tasks, etapes, counts, filter, csrfToken)
|
|
} else if activeTab == "files" {
|
|
@FilesTabFragment(tablo, files, csrfToken)
|
|
} else if activeTab == "events" {
|
|
@EventsTabFragment(tablo, events, csrfToken)
|
|
} else if activeTab == "discussion" {
|
|
@DiscussionTabFragment(tablo, discussion, DiscussionForm{}, DiscussionErrors{}, csrfToken)
|
|
} else {
|
|
@TabloOverviewTabFragment(tablo, csrfToken)
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// TabloOverviewTabFragment renders the overview tab content — description inline-edit zone.
|
|
// Returned as a standalone fragment for HTMX tab-switch responses.
|
|
// Description zone relocated here from the persistent header (Phase 16 Plan 02).
|
|
templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) {
|
|
<div class="overview-tab">
|
|
<div class="tablo-desc-zone">
|
|
@TabloDescDisplay(tablo, csrfToken)
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// TasksTabFragment wraps the KanbanBoard for use as a standalone HTMX tab fragment.
|
|
// Returned by TabloTasksTabHandler on HX-Request == "true".
|
|
// Lives in tablos.templ (tablo-level concern) per plan D-07.
|
|
templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, csrfToken string) {
|
|
<div id="tasks-tab">
|
|
@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)
|
|
</div>
|
|
}
|
|
|
|
// TabloTitleDisplay renders the tablo title as a clickable element that swaps
|
|
// to the edit form on click. The outermost element carries class tablo-title-zone
|
|
// so hx-target="closest .tablo-title-zone" + hx-swap="outerHTML" round-trips cleanly.
|
|
// UI-SPEC §4 Interaction Contract — title inline-edit display state.
|
|
templ TabloTitleDisplay(tablo sqlc.Tablo, csrfToken string) {
|
|
<h1
|
|
class="tablo-title-zone text-xl md:text-3xl font-bold text-foreground cursor-pointer transition-colors"
|
|
hx-get={ "/tablos/" + tablo.ID.String() + "/edit-title" }
|
|
hx-target="closest .tablo-title-zone"
|
|
hx-swap="outerHTML"
|
|
role="button"
|
|
aria-label="Edit title"
|
|
>{ tablo.Title }</h1>
|
|
}
|
|
|
|
// TabloTitleEditFragment renders the inline title edit form. Carries class
|
|
// tablo-title-zone on the form element so outerHTML round-trips work correctly.
|
|
// Includes hidden description field (current value) + hidden _zone="title" for
|
|
// TabloUpdateHandler to know which display fragment to render on success.
|
|
// UI-SPEC §4 Interaction Contract — title inline-edit edit state.
|
|
templ TabloTitleEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string) {
|
|
<form
|
|
class="tablo-title-zone space-y-3"
|
|
method="POST"
|
|
action={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
|
|
hx-post={ "/tablos/" + tablo.ID.String() }
|
|
hx-target="closest .tablo-title-zone"
|
|
hx-swap="outerHTML"
|
|
>
|
|
@ui.CSRFField(csrfToken)
|
|
<input type="hidden" name="_zone" value="title"/>
|
|
<input type="hidden" name="description" value={ tablo.Description.String }/>
|
|
<div>
|
|
<label for="title" class="block text-sm font-medium text-slate-700">Title</label>
|
|
<input
|
|
id="title"
|
|
type="text"
|
|
name="title"
|
|
value={ tablo.Title }
|
|
required
|
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
/>
|
|
@FieldError(errs.Title)
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Save changes",
|
|
Variant: ui.ButtonVariantDefault,
|
|
Tone: ui.ButtonToneSolid,
|
|
Size: ui.SizeMD,
|
|
Type: "submit",
|
|
})
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Discard changes",
|
|
Variant: ui.ButtonVariantNeutral,
|
|
Tone: ui.ButtonToneSoft,
|
|
Size: ui.SizeMD,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"hx-get": "/tablos/" + tablo.ID.String() + "/show-title",
|
|
"hx-target": "closest .tablo-title-zone",
|
|
"hx-swap": "outerHTML",
|
|
},
|
|
})
|
|
</div>
|
|
</form>
|
|
}
|
|
|
|
// TabloDescDisplay renders the tablo description as a clickable element that
|
|
// swaps to the edit form on click. Carries class tablo-desc-zone on the outermost
|
|
// element for clean outerHTML round-trips.
|
|
// UI-SPEC §4 Interaction Contract — description inline-edit display state.
|
|
templ TabloDescDisplay(tablo sqlc.Tablo, csrfToken string) {
|
|
<div
|
|
class="tablo-desc-zone mt-4 cursor-pointer hover:text-slate-600"
|
|
hx-get={ "/tablos/" + tablo.ID.String() + "/edit-desc" }
|
|
hx-target="closest .tablo-desc-zone"
|
|
hx-swap="outerHTML"
|
|
role="button"
|
|
aria-label="Edit description"
|
|
>
|
|
if tablo.Description.Valid && tablo.Description.String != "" {
|
|
<p class="text-base text-slate-600">{ tablo.Description.String }</p>
|
|
} else {
|
|
<p class="text-sm text-slate-400 italic">Add a description</p>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// TabloDescEditFragment renders the inline description edit form. Carries class
|
|
// tablo-desc-zone on the form element so outerHTML round-trips work correctly.
|
|
// Includes hidden title field (current value) + hidden _zone="desc" for
|
|
// TabloUpdateHandler to know which display fragment to render on success.
|
|
// UI-SPEC §4 Interaction Contract — description inline-edit edit state.
|
|
templ TabloDescEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string) {
|
|
<form
|
|
class="tablo-desc-zone mt-4 space-y-3"
|
|
method="POST"
|
|
action={ templ.SafeURL("/tablos/" + tablo.ID.String()) }
|
|
hx-post={ "/tablos/" + tablo.ID.String() }
|
|
hx-target="closest .tablo-desc-zone"
|
|
hx-swap="outerHTML"
|
|
>
|
|
@ui.CSRFField(csrfToken)
|
|
<input type="hidden" name="_zone" value="desc"/>
|
|
<input type="hidden" name="title" value={ tablo.Title }/>
|
|
<div>
|
|
<label for="description" class="block text-sm font-medium text-slate-700">Description <span class="text-slate-400">(optional)</span></label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
rows="3"
|
|
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
>{ tablo.Description.String }</textarea>
|
|
@FieldError(errs.Description)
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Save changes",
|
|
Variant: ui.ButtonVariantDefault,
|
|
Tone: ui.ButtonToneSolid,
|
|
Size: ui.SizeMD,
|
|
Type: "submit",
|
|
})
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Discard changes",
|
|
Variant: ui.ButtonVariantNeutral,
|
|
Tone: ui.ButtonToneSoft,
|
|
Size: ui.SizeMD,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"hx-get": "/tablos/" + tablo.ID.String() + "/show-desc",
|
|
"hx-target": "closest .tablo-desc-zone",
|
|
"hx-swap": "outerHTML",
|
|
},
|
|
})
|
|
</div>
|
|
</form>
|
|
}
|
|
|
|
// TabloDeleteButtonFragment renders the delete trigger button wrapped in a
|
|
// .tablo-delete-zone div. This is the canonical single source of truth for the
|
|
// delete zone shape — TabloCard delegates to this component (no duplication).
|
|
// UI-SPEC §5 Interaction Contract — delete display state.
|
|
templ TabloDeleteButtonFragment(tablo sqlc.Tablo, csrfToken string) {
|
|
<div class="tablo-delete-zone">
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Delete",
|
|
Variant: ui.ButtonVariantDanger,
|
|
Tone: ui.ButtonToneSoft,
|
|
Size: ui.SizeMD,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"hx-get": "/tablos/" + tablo.ID.String() + "/delete-confirm",
|
|
"hx-target": "closest .tablo-delete-zone",
|
|
"hx-swap": "outerHTML",
|
|
"aria-label": "Delete tablo",
|
|
},
|
|
})
|
|
</div>
|
|
}
|
|
|
|
// TabloDeleteConfirmFragment renders the inline confirmation dialog that appears
|
|
// when the user clicks Delete. Carries class tablo-delete-zone for outerHTML
|
|
// round-trips. "Yes, delete" submits the DELETE action; "Keep tablo" restores
|
|
// the original Delete button via TabloDeleteCancelHandler.
|
|
// UI-SPEC §5 Interaction Contract — delete confirmation state.
|
|
templ TabloDeleteConfirmFragment(tablo sqlc.Tablo, csrfToken string) {
|
|
<div class="tablo-delete-zone space-y-3">
|
|
<p class="text-base font-semibold text-slate-800">Delete tablo?</p>
|
|
<p class="text-sm text-slate-600">This cannot be undone.</p>
|
|
<div class="flex items-center gap-3">
|
|
<form
|
|
method="POST"
|
|
action={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/delete") }
|
|
hx-post={ "/tablos/" + tablo.ID.String() + "/delete" }
|
|
hx-target="closest .tablo-delete-zone"
|
|
hx-swap="outerHTML"
|
|
>
|
|
@ui.CSRFField(csrfToken)
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Yes, delete",
|
|
Variant: ui.ButtonVariantDanger,
|
|
Tone: ui.ButtonToneSolid,
|
|
Size: ui.SizeMD,
|
|
Type: "submit",
|
|
Attrs: templ.Attributes{
|
|
"aria-label": "Confirm delete tablo",
|
|
},
|
|
})
|
|
</form>
|
|
@ui.Button(ui.ButtonProps{
|
|
Label: "Keep tablo",
|
|
Variant: ui.ButtonVariantNeutral,
|
|
Tone: ui.ButtonToneSoft,
|
|
Size: ui.SizeMD,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"hx-get": "/tablos/" + tablo.ID.String() + "/delete-cancel",
|
|
"hx-target": "closest .tablo-delete-zone",
|
|
"hx-swap": "outerHTML",
|
|
"aria-label": "Keep tablo",
|
|
},
|
|
})
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// TabloNotFoundPage renders a 404 page for tablos that don't exist or are not
|
|
// accessible by the current user (D-04: 404 not 403 to avoid existence leakage).
|
|
// activePath and sidebarTablos drive the AppLayout sidebar (pass "" and empty slice for not-found).
|
|
// UI-SPEC Copywriting Contract: "Not found" + "This tablo doesn't exist or you don't have access."
|
|
templ TabloNotFoundPage(user *auth.User, csrfToken string, activePath string, sidebarTablos []sqlc.Tablo) {
|
|
@AppLayout("Not found", user, csrfToken, activePath, sidebarTablos, "Not found", nil, nil) {
|
|
<div class="py-16 text-center">
|
|
<h1 class="text-2xl font-semibold leading-snug text-slate-800">Not found</h1>
|
|
<p class="mt-2 text-base text-slate-600">This tablo doesn't exist or you don't have access.</p>
|
|
<div class="mt-6">
|
|
<a href="/" class="text-sm font-medium text-blue-600 hover:underline">Back to tablos</a>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|