369 lines
9.9 KiB
Go
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]))
|
|
}
|