Refactor tasks UI to use reusable button and select components

Replace inline button markup with `ui.Button` component calls for
consistency and maintainability. Add filter menu component with dropdown
functionality. Convert roadmap mode toggle from link-based to select
dropdown. Include filter counter badge and clear filter actions.
This commit is contained in:
Arthur Belleville 2026-05-10 23:38:19 +02:00
parent c80a8a875e
commit d9bf94583b
No known key found for this signature in database
6 changed files with 1115 additions and 587 deletions

View file

@ -36,7 +36,7 @@
.ui-button-md { .ui-button-md {
font-size: 0.95rem; font-size: 0.95rem;
padding: 0.75rem 1.1rem; padding: 0.7rem 1rem;
} }
.ui-button-lg { .ui-button-lg {

View file

@ -1,24 +1,38 @@
package views package views
import taskmodel "xtablo-backend/internal/tasks" import taskmodel "xtablo-backend/internal/tasks"
import "xtablo-backend/internal/web/ui"
templ TasksPageContent(vm TasksPageViewModel) { templ TasksPageContent(vm TasksPageViewModel) {
<div class="min-h-screen" data-current-view={ string(vm.State.View) }> <div class="min-h-screen" data-current-view={ string(vm.State.View) }>
<div class="px-4 md:px-6 pt-6 md:pt-10 pb-5"> <div class="px-4 md:px-6 pt-6 md:pt-10 pb-5">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Mes Tâches</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Mes Tâches</h1>
<button class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 h-9 px-4 py-2 has-[>svg]:px-3 bg-purple-600 hover:bg-purple-700 text-white w-full md:w-auto gap-2" type="button"> @ui.Button(ui.ButtonProps{
@TasksIcon("plus", "lucide lucide-plus w-4 h-4") Label: "Nouvelle tâche",
Nouvelle tâche Variant: ui.ButtonVariantDefault,
</button> Size: ui.SizeMD,
Type: "button",
Icon: "plus",
})
</div> </div>
@TasksViewTabs(vm.State) @TasksViewTabs(vm.State)
<div class="flex flex-col md:flex-row md:items-center md:justify-end gap-3"> <div class="flex flex-col md:flex-row md:items-center md:justify-end gap-3">
<button class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3 w-full md:w-auto gap-2 bg-transparent" type="button"> @ui.Button(ui.ButtonProps{
@TasksIcon("settings2", "lucide lucide-settings2 w-4 h-4") Label: tasksFilterSummaryLabel(vm.Filters),
Filtrer Variant: ui.ButtonVariantNeutral,
</button> Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "button",
Icon: "filter",
Attrs: templ.Attributes{
"data-tasks-filter-trigger": "",
"aria-haspopup": "menu",
"aria-expanded": "false",
},
})
</div> </div>
@TasksFilterMenu(vm)
</div> </div>
<main class="px-4 md:px-6 pb-6"> <main class="px-4 md:px-6 pb-6">
if !vm.HasTasks { if !vm.HasTasks {
@ -36,17 +50,168 @@ templ TasksPageContent(vm TasksPageViewModel) {
</div> </div>
} }
templ TasksFilterMenu(vm TasksPageViewModel) {
<div class="relative">
<form method="get" action="/tasks" class="hidden absolute right-0 top-2 z-50 w-56 max-h-[28rem] overflow-y-auto overflow-x-hidden rounded-md border bg-white dark:bg-gray-900 p-1 text-gray-900 dark:text-gray-100 shadow-md" data-tasks-filter-menu role="menu" aria-orientation="vertical">
<input type="hidden" name="view" value={ string(vm.State.View) }/>
if vm.State.View == taskmodel.TaskViewRoadmap {
<input type="hidden" name="roadmap_mode" value={ string(vm.State.RoadmapMode) }/>
}
<div class="px-2 py-1.5 text-sm font-semibold">Projet</div>
<div class="-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-700"></div>
<a href={ templ.SafeURL(tasksClearTabloFiltersHref(vm.State)) } class="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-800">
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
if tasksFilterGroupAllSelected(vm.Filters.Tablos) {
@TasksIcon("check", "h-4 w-4")
}
</span>
Tous les projets
</a>
for _, option := range vm.Filters.Tablos {
<label class="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-800">
<input class="sr-only" type="checkbox" name="tablo" value={ option.Value } if option.Selected {
checked
} onchange="this.form.requestSubmit()"/>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
if option.Selected {
@TasksIcon("check", "h-4 w-4")
}
</span>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full shrink-0 bg-blue-500"></div>
{ option.Label }
</div>
</label>
}
<div class="-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-700"></div>
<div class="px-2 py-1.5 text-sm font-semibold">Statut</div>
<div class="-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-700"></div>
<a href={ templ.SafeURL(tasksClearStatusFiltersHref(vm.State)) } class="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-800">
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
if tasksFilterGroupAllSelected(vm.Filters.Statuses) {
@TasksIcon("check", "h-4 w-4")
}
</span>
Tous
</a>
for _, option := range vm.Filters.Statuses {
<label class="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-800">
<input class="sr-only" type="checkbox" name="status" value={ option.Value } if option.Selected {
checked
} onchange="this.form.requestSubmit()"/>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
if option.Selected {
@TasksIcon("check", "h-4 w-4")
}
</span>
{ option.Label }
</label>
}
<div class="-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-700"></div>
<div class="px-2 py-1.5 text-sm font-semibold">Assigné</div>
<div class="-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-700"></div>
<a href={ templ.SafeURL(tasksClearAssigneeFiltersHref(vm.State)) } class="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-800">
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
if tasksFilterGroupAllSelected(vm.Filters.Assignees) {
@TasksIcon("check", "h-4 w-4")
}
</span>
Tous
</a>
for _, option := range vm.Filters.Assignees {
<label class="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-gray-100 dark:hover:bg-gray-800">
<input class="sr-only" type="checkbox" name="assignee" value={ option.Value } if option.Selected {
checked
} onchange="this.form.requestSubmit()"/>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
if option.Selected {
@TasksIcon("check", "h-4 w-4")
}
</span>
{ option.Label }
</label>
}
<div class="-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-700"></div>
<div class="p-1">
<a href={ templ.SafeURL(stateAction("/tasks", taskmodel.TaskPageState{View: vm.State.View, RoadmapMode: vm.State.RoadmapMode})) } class="block rounded-sm px-2 py-1.5 text-sm font-medium text-[#667085] hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100">Réinitialiser</a>
</div>
</form>
<script>
(function () {
if (window.__tasksFilterMenuInit) {
window.__tasksFilterMenuInit(document);
return;
}
window.__tasksFilterMenuInit = function (scope) {
(scope || document).querySelectorAll("[data-tasks-filter-trigger]").forEach(function (trigger) {
if (trigger.dataset.tasksFilterBound === "true") {
return;
}
trigger.dataset.tasksFilterBound = "true";
var shell = trigger.closest(".px-4, .md\\:px-6") || trigger.parentElement;
var menu = shell ? shell.querySelector("[data-tasks-filter-menu]") : null;
if (!menu) {
return;
}
trigger.addEventListener("click", function (event) {
event.preventDefault();
var open = !menu.classList.contains("hidden");
document.querySelectorAll("[data-tasks-filter-menu]").forEach(function (other) {
other.classList.add("hidden");
});
document.querySelectorAll("[data-tasks-filter-trigger]").forEach(function (otherTrigger) {
otherTrigger.setAttribute("aria-expanded", "false");
});
if (!open) {
menu.classList.remove("hidden");
trigger.setAttribute("aria-expanded", "true");
}
});
});
};
document.addEventListener("click", function (event) {
if (event.target.closest("[data-tasks-filter-menu]") || event.target.closest("[data-tasks-filter-trigger]")) {
return;
}
document.querySelectorAll("[data-tasks-filter-menu]").forEach(function (menu) {
menu.classList.add("hidden");
});
document.querySelectorAll("[data-tasks-filter-trigger]").forEach(function (trigger) {
trigger.setAttribute("aria-expanded", "false");
});
});
document.addEventListener("htmx:afterSwap", function (event) {
window.__tasksFilterMenuInit(event.target);
});
window.__tasksFilterMenuInit(document);
})();
</script>
</div>
}
templ TasksViewTabs(state taskmodel.TaskPageState) { templ TasksViewTabs(state taskmodel.TaskPageState) {
<div class="flex flex-wrap items-center gap-2 md:gap-6 mb-4 border-b border-[#EAECF0] dark:border-gray-700"> <div class="flex flex-wrap items-center gap-2 md:gap-6 mb-4 border-b border-[#EAECF0] dark:border-gray-700">
<a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewKanban)) } class={ taskViewTabClass(state, taskmodel.TaskViewKanban) } if state.View == taskmodel.TaskViewKanban { aria-current="page" }> <a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewKanban)) } class={ taskViewTabClass(state, taskmodel.TaskViewKanban) } if state.View == taskmodel.TaskViewKanban {
aria-current="page"
}>
@TasksIcon("kanban", "w-4 h-4") @TasksIcon("kanban", "w-4 h-4")
<span>Tableau</span> <span>Tableau</span>
</a> </a>
<a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewList)) } class={ taskViewTabClass(state, taskmodel.TaskViewList) } if state.View == taskmodel.TaskViewList { aria-current="page" }> <a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewList)) } class={ taskViewTabClass(state, taskmodel.TaskViewList) } if state.View == taskmodel.TaskViewList {
aria-current="page"
}>
@TasksIcon("list", "w-4 h-4") @TasksIcon("list", "w-4 h-4")
<span>Liste</span> <span>Liste</span>
</a> </a>
<a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewRoadmap)) } class={ taskViewTabClass(state, taskmodel.TaskViewRoadmap) } if state.View == taskmodel.TaskViewRoadmap { aria-current="page" }> <a href={ templ.SafeURL(taskViewHref(state, taskmodel.TaskViewRoadmap)) } class={ taskViewTabClass(state, taskmodel.TaskViewRoadmap) } if state.View == taskmodel.TaskViewRoadmap {
aria-current="page"
}>
@TasksIcon("map", "w-4 h-4") @TasksIcon("map", "w-4 h-4")
<span>Roadmap</span> <span>Roadmap</span>
</a> </a>
@ -57,13 +222,8 @@ templ TasksViewTabs(state taskmodel.TaskPageState) {
</button> </button>
</div> </div>
if state.View == taskmodel.TaskViewRoadmap { if state.View == taskmodel.TaskViewRoadmap {
<div class="mb-4 flex flex-wrap items-center gap-2"> <div class="mb-4 max-w-[220px]">
<a href={ templ.SafeURL(taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeWeek)) } class={ taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeWeek) }> @ui.Select(taskRoadmapModeSelectProps(state))
Semaine
</a>
<a href={ templ.SafeURL(taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeMonth)) } class={ taskRoadmapModeClass(state, taskmodel.TaskRoadmapModeMonth) }>
Mois
</a>
</div> </div>
} }
} }
@ -74,13 +234,17 @@ templ TasksKanbanLayout(view TasksKanbanView, state taskmodel.TaskPageState) {
<div class="w-full h-fit bg-[#F9FAFB] dark:bg-gray-800/60 rounded-[12px] p-4" data-status-column={ column.ID }> <div class="w-full h-fit bg-[#F9FAFB] dark:bg-gray-800/60 rounded-[12px] p-4" data-status-column={ column.ID }>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(column.ID)) @TasksIcon("circle", "lucide lucide-circle w-5 h-5 "+statusIconClass(column.ID))
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{ column.Label }</h2> <h2 class="font-semibold text-gray-800 dark:text-gray-100">{ column.Label }</h2>
<span class="bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-medium px-2 py-0.5 rounded-full">{ len(column.Tasks) }</span> <span class="bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-medium px-2 py-0.5 rounded-full">{ len(column.Tasks) }</span>
</div> </div>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"> @ui.IconButton(ui.IconButtonProps{
@TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]") Label: "Nouvelle tâche dans " + column.Label,
</button> Icon: "plus",
Variant: ui.IconButtonVariantNeutral,
Tone: ui.IconButtonToneGhost,
Type: "button",
})
</div> </div>
<div class="space-y-3 pr-1 min-h-[80px]"> <div class="space-y-3 pr-1 min-h-[80px]">
for _, task := range column.Tasks { for _, task := range column.Tasks {
@ -98,13 +262,17 @@ templ TasksListLayout(view TasksListView, state taskmodel.TaskPageState) {
<section class="rounded-[12px] bg-[#F9FAFB] dark:bg-gray-800/60 p-4" data-status-group={ group.ID }> <section class="rounded-[12px] bg-[#F9FAFB] dark:bg-gray-800/60 p-4" data-status-group={ group.ID }>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@TasksIcon("circle", "lucide lucide-circle w-5 h-5 " + statusIconClass(group.ID)) @TasksIcon("circle", "lucide lucide-circle w-5 h-5 "+statusIconClass(group.ID))
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{ group.Label }</h2> <h2 class="font-semibold text-gray-800 dark:text-gray-100">{ group.Label }</h2>
<span class="bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-medium px-2 py-0.5 rounded-full">{ len(group.Tasks) }</span> <span class="bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-medium px-2 py-0.5 rounded-full">{ len(group.Tasks) }</span>
</div> </div>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"> @ui.IconButton(ui.IconButtonProps{
@TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]") Label: "Nouvelle tâche dans " + group.Label,
</button> Icon: "plus",
Variant: ui.IconButtonVariantNeutral,
Tone: ui.IconButtonToneGhost,
Type: "button",
})
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
for _, task := range group.Tasks { for _, task := range group.Tasks {
@ -125,9 +293,13 @@ templ TasksRoadmapLayout(view TasksRoadmapView, state taskmodel.TaskPageState) {
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{ lane.Label }</h2> <h2 class="font-semibold text-gray-800 dark:text-gray-100">{ lane.Label }</h2>
<p class="text-xs text-[#667085] dark:text-gray-400">Étape comme lane horizontale, avec bucketisation par date d'échéance.</p> <p class="text-xs text-[#667085] dark:text-gray-400">Étape comme lane horizontale, avec bucketisation par date d'échéance.</p>
</div> </div>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 rounded p-2 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"> @ui.IconButton(ui.IconButtonProps{
@TasksIcon("plus", "lucide lucide-plus w-[18px] h-[18px]") Label: "Nouvelle tâche dans " + lane.Label,
</button> Icon: "plus",
Variant: ui.IconButtonVariantNeutral,
Tone: ui.IconButtonToneGhost,
Type: "button",
})
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
for _, bucket := range lane.Buckets { for _, bucket := range lane.Buckets {
@ -153,12 +325,30 @@ templ TaskCard(task TaskCardView, state taskmodel.TaskPageState, compact bool) {
<article draggable="true" class={ taskCardClass(compact) } data-task-id={ task.ID }> <article draggable="true" class={ taskCardClass(compact) } data-task-id={ task.ID }>
<div class="flex items-start justify-between gap-2 mb-2"> <div class="flex items-start justify-between gap-2 mb-2">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight line-clamp-2 flex-1">{ task.Title }</h3> <h3 class="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight line-clamp-2 flex-1">{ task.Title }</h3>
<button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0 p-2 -m-1 min-h-[44px] min-w-[44px] flex items-center justify-center" hx-get={ taskEditHref(task, state) } hx-target="#app-main-content" hx-swap="beforeend"> @ui.IconButton(ui.IconButtonProps{
@TasksIcon("ellipsis-vertical", "lucide lucide-ellipsis-vertical w-4 h-4") Label: "Modifier la tâche " + task.Title,
</button> Icon: "pencil",
<button type="button" aria-label={ taskDeleteAriaLabel(task) } class="text-gray-400 hover:text-red-500 shrink-0 p-2 -m-1 min-h-[44px] min-w-[44px] flex items-center justify-center" hx-delete={ taskDeleteHref(task, state) } hx-target="#app-main-content" hx-swap="outerHTML"> Variant: ui.IconButtonVariantNeutral,
@TasksIcon("trash2", "lucide lucide-trash2 w-4 h-4") Tone: ui.IconButtonToneGhost,
</button> Type: "button",
Attrs: templ.Attributes{
"hx-get": taskEditHref(task, state),
"hx-target": "#app-main-content",
"hx-swap": "beforeend",
},
})
@ui.IconButton(ui.IconButtonProps{
Label: taskDeleteAriaLabel(task),
Icon: "trash",
Variant: ui.IconButtonVariantDanger,
Tone: ui.IconButtonToneGhost,
Type: "button",
Attrs: templ.Attributes{
"hx-delete": taskDeleteHref(task, state),
"hx-target": "#app-main-content",
"hx-swap": "outerHTML",
},
})
</div> </div>
if task.DueDate != "" { if task.DueDate != "" {
<div class={ "flex items-center text-xs mb-3 " + dueDateToneClass(task.DueDateValue) }> <div class={ "flex items-center text-xs mb-3 " + dueDateToneClass(task.DueDateValue) }>
@ -320,6 +510,10 @@ templ TasksIcon(kind string, className string) {
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<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"></path> <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"></path>
</svg> </svg>
case "check":
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={ className }>
<path d="M20 6 9 17l-5-5"></path>
</svg>
default: default:
@TasksIcon("circle", className) @TasksIcon("circle", className)
} }

File diff suppressed because it is too large Load diff

View file

@ -3,12 +3,14 @@ package views
import ( import (
"net/url" "net/url"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos" tablomodel "xtablo-backend/internal/tablos"
taskmodel "xtablo-backend/internal/tasks" taskmodel "xtablo-backend/internal/tasks"
"xtablo-backend/internal/web/ui"
) )
type TasksPageViewModel struct { type TasksPageViewModel struct {
@ -391,6 +393,75 @@ func taskRoadmapModeClass(state taskmodel.TaskPageState, mode taskmodel.TaskRoad
return base + "bg-gray-100 text-gray-600 border border-transparent hover:text-gray-900 dark:bg-gray-800 dark:text-gray-300" return base + "bg-gray-100 text-gray-600 border border-transparent hover:text-gray-900 dark:bg-gray-800 dark:text-gray-300"
} }
func taskRoadmapModeSelectProps(state taskmodel.TaskPageState) ui.SelectProps {
return ui.SelectProps{
ID: "tasks-roadmap-mode",
Name: "roadmap_mode_nav",
Value: taskRoadmapModeHref(state, state.RoadmapMode),
Options: []ui.SelectOption{
{Value: taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeWeek), Label: "Semaine"},
{Value: taskRoadmapModeHref(state, taskmodel.TaskRoadmapModeMonth), Label: "Mois"},
},
Attrs: map[string]any{
"onchange": "if (this.value) window.location.href=this.value",
},
}
}
func tasksFilterSummaryLabel(filters TasksFiltersView) string {
count := 0
for _, option := range filters.Tablos {
if option.Selected {
count++
}
}
for _, option := range filters.Statuses {
if option.Selected {
count++
}
}
for _, option := range filters.Assignees {
if option.Selected {
count++
}
}
if count == 0 {
return "Filtrer"
}
return "Filtrer (" + strconv.Itoa(count) + ")"
}
func tasksFilterGroupAllSelected(options []TasksOptionView) bool {
for _, option := range options {
if option.Selected {
return false
}
}
return true
}
func tasksFilterGroupHasChoices(options []TasksOptionView) bool {
return len(options) > 0
}
func tasksClearTabloFiltersHref(state taskmodel.TaskPageState) string {
nextState := state
nextState.TabloIDs = nil
return stateAction("/tasks", nextState)
}
func tasksClearStatusFiltersHref(state taskmodel.TaskPageState) string {
nextState := state
nextState.Statuses = nil
return stateAction("/tasks", nextState)
}
func tasksClearAssigneeFiltersHref(state taskmodel.TaskPageState) string {
nextState := state
nextState.AssigneeIDs = nil
return stateAction("/tasks", nextState)
}
func taskCardClass(compact bool) string { func taskCardClass(compact bool) string {
if compact { if compact {
return "bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700" return "bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm border border-gray-100 dark:border-gray-700"

View file

@ -429,7 +429,7 @@ input {
.ui-button-md { .ui-button-md {
font-size: 0.95rem; font-size: 0.95rem;
padding: 0.75rem 1.1rem; padding: 0.7rem 1rem;
} }
.ui-button-lg { .ui-button-lg {

View file

@ -58,6 +58,7 @@
--tracking-wide: 0.025em; --tracking-wide: 0.025em;
--tracking-wider: 0.05em; --tracking-wider: 0.05em;
--leading-tight: 1.25; --leading-tight: 1.25;
--radius-sm: 0.25rem;
--radius-md: 0.375rem; --radius-md: 0.375rem;
--radius-lg: 0.5rem; --radius-lg: 0.5rem;
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
@ -73,6 +74,17 @@
.visible { .visible {
visibility: visible; visibility: visible;
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
.absolute { .absolute {
position: absolute; position: absolute;
} }
@ -88,18 +100,33 @@
.top-1\/2 { .top-1\/2 {
top: calc(1/2 * 100%); top: calc(1/2 * 100%);
} }
.top-2 {
top: calc(var(--spacing) * 2);
}
.right-0 {
right: calc(var(--spacing) * 0);
}
.left-2 {
left: calc(var(--spacing) * 2);
}
.left-3 { .left-3 {
left: calc(var(--spacing) * 3); left: calc(var(--spacing) * 3);
} }
.isolate { .isolate {
isolation: isolate; isolation: isolate;
} }
.-m-1 { .z-50 {
margin: calc(var(--spacing) * -1); z-index: 50;
}
.-mx-1 {
margin-inline: calc(var(--spacing) * -1);
} }
.-mx-4 { .-mx-4 {
margin-inline: calc(var(--spacing) * -4); margin-inline: calc(var(--spacing) * -4);
} }
.my-1 {
margin-block: calc(var(--spacing) * 1);
}
.mt-1 { .mt-1 {
margin-top: calc(var(--spacing) * 1); margin-top: calc(var(--spacing) * 1);
} }
@ -136,6 +163,9 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.block {
display: block;
}
.flex { .flex {
display: flex; display: flex;
} }
@ -211,15 +241,15 @@
.h-8 { .h-8 {
height: calc(var(--spacing) * 8); height: calc(var(--spacing) * 8);
} }
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-\[18px\] {
height: 18px;
}
.h-fit { .h-fit {
height: fit-content; height: fit-content;
} }
.h-px {
height: 1px;
}
.max-h-\[28rem\] {
max-height: 28rem;
}
.min-h-\[44px\] { .min-h-\[44px\] {
min-height: 44px; min-height: 44px;
} }
@ -232,6 +262,9 @@
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-3 { .w-3 {
width: calc(var(--spacing) * 3); width: calc(var(--spacing) * 3);
} }
@ -253,14 +286,14 @@
.w-12 { .w-12 {
width: calc(var(--spacing) * 12); width: calc(var(--spacing) * 12);
} }
.w-\[18px\] { .w-56 {
width: 18px; width: calc(var(--spacing) * 56);
} }
.w-full { .w-full {
width: 100%; width: 100%;
} }
.min-w-\[44px\] { .max-w-\[220px\] {
min-width: 44px; max-width: 220px;
} }
.min-w-\[80px\] { .min-w-\[80px\] {
min-width: 80px; min-width: 80px;
@ -372,8 +405,11 @@
.overflow-x-auto { .overflow-x-auto {
overflow-x: auto; overflow-x: auto;
} }
.rounded { .overflow-x-hidden {
border-radius: 0.25rem; overflow-x: hidden;
}
.overflow-y-auto {
overflow-y: auto;
} }
.rounded-\[5px\] { .rounded-\[5px\] {
border-radius: 5px; border-radius: 5px;
@ -393,6 +429,9 @@
.rounded-md { .rounded-md {
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.rounded-sm {
border-radius: var(--radius-sm);
}
.rounded-xl { .rounded-xl {
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
} }
@ -510,9 +549,6 @@
.bg-teal-500 { .bg-teal-500 {
background-color: var(--color-teal-500); background-color: var(--color-teal-500);
} }
.bg-transparent {
background-color: transparent;
}
.bg-white { .bg-white {
background-color: var(--color-white); background-color: var(--color-white);
} }
@ -527,8 +563,8 @@
.bg-yellow-500 { .bg-yellow-500 {
background-color: var(--color-yellow-500); background-color: var(--color-yellow-500);
} }
.p-2 { .p-1 {
padding: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 1);
} }
.p-3 { .p-3 {
padding: calc(var(--spacing) * 3); padding: calc(var(--spacing) * 3);
@ -560,9 +596,6 @@
.py-1\.5 { .py-1\.5 {
padding-block: calc(var(--spacing) * 1.5); padding-block: calc(var(--spacing) * 1.5);
} }
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.py-2\.5 { .py-2\.5 {
padding-block: calc(var(--spacing) * 2.5); padding-block: calc(var(--spacing) * 2.5);
} }
@ -587,6 +620,9 @@
.pr-1 { .pr-1 {
padding-right: calc(var(--spacing) * 1); padding-right: calc(var(--spacing) * 1);
} }
.pr-2 {
padding-right: calc(var(--spacing) * 2);
}
.pr-4 { .pr-4 {
padding-right: calc(var(--spacing) * 4); padding-right: calc(var(--spacing) * 4);
} }
@ -599,6 +635,9 @@
.pb-6 { .pb-6 {
padding-bottom: calc(var(--spacing) * 6); padding-bottom: calc(var(--spacing) * 6);
} }
.pl-8 {
padding-left: calc(var(--spacing) * 8);
}
.pl-10 { .pl-10 {
padding-left: calc(var(--spacing) * 10); padding-left: calc(var(--spacing) * 10);
} }
@ -725,12 +764,12 @@
.opacity-40 { .opacity-40 {
opacity: 40%; opacity: 40%;
} }
.shadow-sm { .shadow-md {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.shadow-xs { .shadow-sm {
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.filter { .filter {
@ -751,6 +790,14 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration)); transition-duration: var(--tw-duration, var(--default-transition-duration));
} }
.outline-none {
--tw-outline-style: none;
outline-style: none;
}
.select-none {
-webkit-user-select: none;
user-select: none;
}
.hover\:bg-gray-50 { .hover\:bg-gray-50 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -758,24 +805,10 @@
} }
} }
} }
.hover\:bg-gray-200 { .hover\:bg-gray-100 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
background-color: var(--color-gray-200); background-color: var(--color-gray-100);
}
}
}
.hover\:bg-purple-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-purple-700);
}
}
}
.hover\:text-gray-600 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-600);
} }
} }
} }
@ -793,13 +826,6 @@
} }
} }
} }
.hover\:text-red-500 {
&:hover {
@media (hover: hover) {
color: var(--color-red-500);
}
}
}
.hover\:shadow-md { .hover\:shadow-md {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -825,39 +851,6 @@
outline-style: none; outline-style: none;
} }
} }
.focus-visible\:ring-2 {
&:focus-visible {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus-visible\:ring-offset-2 {
&:focus-visible {
--tw-ring-offset-width: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
}
}
.focus-visible\:outline-none {
&:focus-visible {
--tw-outline-style: none;
outline-style: none;
}
}
.disabled\:pointer-events-none {
&:disabled {
pointer-events: none;
}
}
.disabled\:opacity-50 {
&:disabled {
opacity: 50%;
}
}
.has-\[\>svg\]\:px-3 {
&:has(>svg) {
padding-inline: calc(var(--spacing) * 3);
}
}
.sm\:mx-0 { .sm\:mx-0 {
@media (width >= 40rem) { @media (width >= 40rem) {
margin-inline: calc(var(--spacing) * 0); margin-inline: calc(var(--spacing) * 0);
@ -878,11 +871,6 @@
width: 350px; width: 350px;
} }
} }
.md\:w-auto {
@media (width >= 48rem) {
width: auto;
}
}
.md\:grid-cols-2 { .md\:grid-cols-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -1093,15 +1081,6 @@
color: var(--color-purple-400); color: var(--color-purple-400);
} }
} }
.dark\:hover\:bg-gray-700 {
&:is(.dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-gray-700);
}
}
}
}
.dark\:hover\:bg-gray-800 { .dark\:hover\:bg-gray-800 {
&:is(.dark *) { &:is(.dark *) {
&:hover { &:hover {
@ -1129,22 +1108,6 @@
} }
} }
} }
.\[\&_svg\]\:pointer-events-none {
& svg {
pointer-events: none;
}
}
.\[\&_svg\]\:size-4 {
& svg {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
}
.\[\&_svg\]\:shrink-0 {
& svg {
flex-shrink: 0;
}
}
.\[\&\>svg\]\:h-4 { .\[\&\>svg\]\:h-4 {
&>svg { &>svg {
height: calc(var(--spacing) * 4); height: calc(var(--spacing) * 4);