xtablo-source/go-backend/internal/web/handlers/tablos.go
2026-05-09 20:18:24 +02:00

369 lines
9.9 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
"xtablo-backend/internal/web/views"
)
var ErrTabloNotFound = tablomodel.ErrNotFound
type TabloStatus = tablomodel.Status
const (
TabloStatusTodo = tablomodel.StatusTodo
TabloStatusInProgress = tablomodel.StatusInProgress
TabloStatusDone = tablomodel.StatusDone
)
type TabloRecord = tablomodel.Record
type CreateTabloInput = tablomodel.CreateInput
type ListTablosInput = tablomodel.ListInput
type TablosPageState struct {
View string
Query string
Status string
ModalOpen bool
}
func normalizeTabloQuery(query string) string {
return strings.ToLower(strings.TrimSpace(query))
}
func parseTablosPageState(values interface {
Get(string) string
}) TablosPageState {
view := strings.TrimSpace(values.Get("view"))
if view != "list" {
view = "grid"
}
status := strings.TrimSpace(values.Get("status"))
switch status {
case "todo", "in_progress", "done":
default:
status = "all"
}
return TablosPageState{
View: view,
Query: strings.TrimSpace(values.Get("q")),
Status: status,
ModalOpen: strings.TrimSpace(values.Get("modal")) == "create",
}
}
func (s TablosPageState) statusFilter() *TabloStatus {
switch s.Status {
case string(TabloStatusTodo):
status := TabloStatusTodo
return &status
case string(TabloStatusInProgress):
status := TabloStatusInProgress
return &status
case string(TabloStatusDone):
status := TabloStatusDone
return &status
default:
return nil
}
}
func (h *AuthHandler) PostTablos() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form payload", http.StatusBadRequest)
return
}
state := parseTablosPageState(r.Form)
state.ModalOpen = true
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, nil, name, "Le nom du projet est requis"), http.StatusUnprocessableEntity)
return
}
if _, err := h.repo.CreateTablo(r.Context(), CreateTabloInput{
OwnerID: user.ID,
Name: name,
Status: TabloStatusTodo,
}); err != nil {
http.Error(w, "failed to create tablo", http.StatusInternalServerError)
return
}
state.ModalOpen = false
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
Query: state.Query,
Status: state.statusFilter(),
})
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK)
}
}
func (h *AuthHandler) DeleteTablo() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tabloID, err := uuid.Parse(r.PathValue("tabloID"))
if err != nil {
http.Error(w, "invalid tablo id", http.StatusBadRequest)
return
}
if err := h.repo.SoftDeleteTablo(r.Context(), tabloID, user.ID); err != nil {
if errors.Is(err, ErrTabloNotFound) {
http.Error(w, "tablo not found", http.StatusNotFound)
return
}
http.Error(w, "failed to delete tablo", http.StatusInternalServerError)
return
}
state := parseTablosPageState(r.URL.Query())
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
Query: state.Query,
Status: state.statusFilter(),
})
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK)
}
}
func (h *AuthHandler) renderTablosPage(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
state := parseTablosPageState(r.URL.Query())
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
Query: state.Query,
Status: state.statusFilter(),
})
if err != nil {
http.Error(w, "failed to list tablos", http.StatusInternalServerError)
return
}
renderTablosResponse(w, r, "/tablos", tablosPageViewModel(user, state, tablos, "", ""), http.StatusOK)
}
func tablosPageViewModel(user PublicUser, state TablosPageState, tablos []TabloRecord, formName string, errorMessage string) views.TablosPageViewModel {
return views.NewTablosPageViewModel(
user.DisplayName,
state.View,
state.Query,
state.Status,
state.ModalOpen,
formName,
errorMessage,
buildTabloCardViews(tablos, state),
)
}
func renderTablosResponse(w http.ResponseWriter, r *http.Request, activePath string, vm views.TablosPageViewModel, statusCode int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusCode)
var err error
content := views.TablosPageContent(vm)
if isHXRequest(r) {
err = views.DashboardContentSwapWithMainClass(activePath, "flex-1 overflow-auto", content).Render(r.Context(), w)
} else {
err = views.DashboardPageWithMainClass(activePath, "flex-1 overflow-auto", content).Render(r.Context(), w)
}
if err != nil {
http.Error(w, "failed to render tablos page", http.StatusInternalServerError)
}
}
func (r *InMemoryAuthRepository) CreateTablo(_ context.Context, input CreateTabloInput) (TabloRecord, error) {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
tablo := TabloRecord{
ID: uuid.New(),
OwnerID: input.OwnerID,
Name: strings.TrimSpace(input.Name),
Status: input.Status,
CreatedAt: now,
UpdatedAt: now,
}
r.tablos[tablo.ID] = tablo
return tablo, nil
}
func (r *InMemoryAuthRepository) ListTablos(_ context.Context, input ListTablosInput) ([]TabloRecord, error) {
r.mu.RLock()
defer r.mu.RUnlock()
query := normalizeTabloQuery(input.Query)
var tablos []TabloRecord
for _, tablo := range r.tablos {
if tablo.OwnerID != input.OwnerID {
continue
}
if tablo.DeletedAt != nil {
continue
}
if input.Status != nil && tablo.Status != *input.Status {
continue
}
if query != "" && !strings.Contains(strings.ToLower(tablo.Name), query) {
continue
}
tablos = append(tablos, tablo)
}
sortTablosByCreatedAtDesc(tablos)
return tablos, nil
}
func (r *InMemoryAuthRepository) SoftDeleteTablo(_ context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
tablo, ok := r.tablos[tabloID]
if !ok || tablo.OwnerID != ownerID || tablo.DeletedAt != nil {
return ErrTabloNotFound
}
now := time.Now().UTC()
tablo.DeletedAt = &now
tablo.UpdatedAt = now
r.tablos[tabloID] = tablo
return nil
}
func sortTablosByCreatedAtDesc(tablos []TabloRecord) {
for i := 0; i < len(tablos); i++ {
for j := i + 1; j < len(tablos); j++ {
if tablos[j].CreatedAt.After(tablos[i].CreatedAt) {
tablos[i], tablos[j] = tablos[j], tablos[i]
}
}
}
}
func buildTabloCardViews(tablos []TabloRecord, state TablosPageState) []views.TabloCardView {
items := make([]views.TabloCardView, 0, len(tablos))
for _, tablo := range tablos {
statusLabel, statusClass, progress, statusTone := tabloStatusPresentation(tablo.Status)
iconKind, bgClass, fgClass, accent := tabloIconPresentation(tablo.Name)
items = append(items, views.TabloCardView{
ID: tablo.ID.String(),
Name: tablo.Name,
Status: string(tablo.Status),
StatusLabel: statusLabel,
StatusClass: statusClass,
StatusTone: statusTone,
Progress: progress,
CreatedAtLabel: formatFrenchDate(tablo.CreatedAt),
CardDateLabel: formatCardDate(tablo.CreatedAt),
ProgressLabel: fmt.Sprintf("%d%%", progress),
DeleteURL: "/tablos/" + tablo.ID.String(),
DeleteRequestURL: buildDeleteRequestURL("/tablos/"+tablo.ID.String(), state),
IconKind: iconKind,
IconBgClass: bgClass,
IconFgClass: fgClass,
Accent: accent,
Initial: projectInitial(tablo.Name),
})
}
return items
}
func buildDeleteRequestURL(path string, state TablosPageState) string {
values := url.Values{}
values.Set("view", state.View)
values.Set("status", state.Status)
if strings.TrimSpace(state.Query) != "" {
values.Set("q", strings.TrimSpace(state.Query))
}
encoded := values.Encode()
if encoded == "" {
return path
}
return path + "?" + encoded
}
func tabloStatusPresentation(status TabloStatus) (string, string, int, string) {
switch status {
case TabloStatusInProgress:
return "En cours", "bg-[#FFF4E2] text-[#DB9729] border border-[#DB9729]", 50, "warning"
case TabloStatusDone:
return "Terminé", "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800", 100, "success"
default:
return "À faire", "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800", 0, "info"
}
}
func tabloIconPresentation(name string) (string, string, string, string) {
switch len(strings.TrimSpace(name)) % 3 {
case 1:
return "gem", "bg-purple-500", "text-white", "purple"
case 2:
return "sparkles", "bg-cyan-500", "text-gray-700", "red"
default:
return "bolt", "bg-blue-500", "text-white", "blue"
}
}
func formatFrenchDate(value time.Time) string {
months := []string{"janv.", "fevr.", "mars", "avr.", "mai", "juin", "juil.", "aout", "sept.", "oct.", "nov.", "dec."}
month := months[int(value.Month())-1]
return fmt.Sprintf("%02d %s %d", value.Day(), month, value.Year())
}
func formatCardDate(value time.Time) string {
return value.Format("Jan 02, 2006")
}
func projectInitial(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "P"
}
runes := []rune(trimmed)
return strings.ToUpper(string(runes[0]))
}